Skip to content

Commit 06ddead

Browse files
committed
feat(app): add a touch dock to the mobile composer
1 parent b92e085 commit 06ddead

7 files changed

Lines changed: 641 additions & 64 deletions

File tree

‎packages/app/e2e/regression/composer.spec.ts‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -883,7 +883,9 @@ test("keeps a line added at the end of a scrolling prompt clear of the bottom fa
883883
const viewport = element.closest('[data-component="composer-scroll"]')!
884884
const range = document.createRange()
885885
range.selectNodeContents(element)
886-
const line = Array.from(range.getClientRects()).reduce((last, rect) => (rect.bottom > last.bottom ? rect : last))
886+
const line = Array.from(range.getClientRects()).reduce((last, rect) =>
887+
rect.bottom > last.bottom ? rect : last,
888+
)
887889
const fade = parseFloat(getComputedStyle(viewport).getPropertyValue("--composer-fade-bottom"))
888890

889891
return Math.max(0, Math.round(line.bottom - (viewport.getBoundingClientRect().bottom - fade)))
@@ -892,6 +894,65 @@ test("keeps a line added at the end of a scrolling prompt clear of the bottom fa
892894
.toBe(0)
893895
})
894896

897+
test("Enter that confirms an IME candidate never sends, even after Safari's early compositionend", async ({ page }) => {
898+
const prompts: Parameters<NonNullable<MockServerConfig["onPrompt"]>>[0]["body"][] = []
899+
const view = await openSession(page, { name: "ComposerImeEnter", onPrompt: (input) => void prompts.push(input.body) })
900+
901+
const enter = (keyCode: number) =>
902+
view.editor.evaluate(
903+
(element, keyCode) =>
904+
element.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", keyCode, bubbles: true, cancelable: true })),
905+
keyCode,
906+
)
907+
908+
await view.editor.fill("にほん")
909+
// Safari delivers the confirming Enter after compositionend, so isComposing is false and only keyCode marks it.
910+
await enter(229)
911+
await expect(view.editor).toHaveText("にほん")
912+
await enter(13)
913+
await expect.poll(() => prompts.map((body) => body.text)).toEqual(["にほん"])
914+
})
915+
916+
test.describe("touch keyboard", () => {
917+
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
918+
919+
test("Return only adds a line, even over suggestions; the touch bar and Cmd+Return send", async ({ page }) => {
920+
const prompts: Parameters<NonNullable<MockServerConfig["onPrompt"]>>[0]["body"][] = []
921+
const view = await openSession(page, {
922+
name: "ComposerTouchReturn",
923+
onPrompt: (input) => void prompts.push(input.body),
924+
})
925+
const bar = page.locator('[data-slot="composer-touch-bar"]')
926+
await expect(bar).toBeVisible()
927+
await expect(page.locator('[data-action="composer-attach"]')).toHaveCount(0)
928+
await expect(view.editor).toHaveAttribute("enterkeyhint", "enter")
929+
930+
await view.editor.fill("/model")
931+
await expect(page.locator('[data-suggestion-id="model.choose"]')).toHaveAttribute("data-active", "")
932+
await view.editor.press("Enter")
933+
await expect(page.locator('[data-component="composer-suggestions"]')).toBeHidden()
934+
await expect.poll(() => view.editor.innerText()).toMatch(/^\/model\n/)
935+
await expect(page.getByRole("dialog", { name: "Select model", exact: true })).toBeHidden()
936+
937+
await view.editor.fill("first line")
938+
await view.editor.press("Enter")
939+
await view.editor.pressSequentially("second line")
940+
await expect.poll(() => view.editor.innerText()).toBe("first line\nsecond line")
941+
expect(prompts).toEqual([])
942+
943+
await bar.getByRole("button", { name: "Send", exact: true }).tap()
944+
await expect.poll(() => prompts.map((body) => body.text)).toEqual(["first line\nsecond line"])
945+
await expect(view.editor).toBeEmpty()
946+
947+
// A hardware keyboard, as on an iPad, still sends with Cmd+Return.
948+
await view.editor.fill("from the iPad keyboard")
949+
await view.editor.press("ControlOrMeta+Enter")
950+
await expect
951+
.poll(() => prompts.map((body) => body.text))
952+
.toEqual(["first line\nsecond line", "from the iPad keyboard"])
953+
})
954+
})
955+
895956
test("a key-up never moves the caret back after later navigation", async ({ page }) => {
896957
const input = await draft(page)
897958
await input.pressSequentially("first line")

‎packages/app/e2e/regression/mobile-composer-submit-focus.spec.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ for (const { width, direction } of [
1616

1717
const editor = page.getByRole("textbox", { name: "Prompt", exact: true })
1818
await expect(editor).toBeEditable()
19+
// A narrow window with a mouse is not a phone: it keeps the desktop toolbar and Enter to send.
20+
await expect(page.locator('[data-action="composer-attach"]')).toBeVisible()
21+
await expect(page.locator('[data-slot="composer-touch-bar"]')).toHaveCount(0)
1922
await editor.fill("Please check focus after sending.")
2023
await expect(editor).toBeFocused()
2124

‎packages/app/e2e/regression/mobile-timeline-scroll.spec.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -732,7 +732,8 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
732732
timestamp: restingTimestamp(),
733733
})
734734

735-
for (let step = 1; step <= 23; step++)
735+
// Far enough that the delayed image above the anchor comes back into the mounted window.
736+
for (let step = 1; step <= 25; step++)
736737
await devtools.send("Input.dispatchTouchEvent", {
737738
type: "touchMove",
738739
touchPoints: [{ x: point.x, y: point.y + step * 30 }],
@@ -767,7 +768,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
767768
await expect(hidden).toHaveCount(1)
768769
}
769770

770-
const anchor = page.getByText(detach === "scroll" ? "Part 48." : "Reading 20.", { exact: true })
771+
const anchor = page.getByText(detach === "scroll" ? "Part 50." : "Reading 20.", { exact: true })
771772
await expect(anchor).toBeInViewport()
772773
const before = await anchor.evaluate((element) => element.getBoundingClientRect().top)
773774
image.resolve()

‎packages/app/e2e/regression/session-queue.spec.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,49 @@ test("a blank Enter leaves the running session alone while Stop still interrupts
260260
expect(mock.prompts).toEqual([])
261261
})
262262

263+
test.describe("touch keyboard", () => {
264+
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
265+
266+
test("the touch bar stops, queues and steers a running session while Return only adds a line", async ({ page }) => {
267+
const mock = createQueueMock([])
268+
const view = await openQueue(page, mock, "steer")
269+
const bar = page.locator('[data-slot="composer-touch-bar"]')
270+
const stop = bar.getByRole("button", { name: "Stop", exact: true })
271+
const interrupted = page.waitForRequest((request) =>
272+
new URL(request.url()).pathname.endsWith(`/${sessionID}/interrupt`),
273+
)
274+
275+
await stop.tap()
276+
await interrupted
277+
278+
await view.input.fill("check the logs")
279+
await expect(stop).toHaveCount(0)
280+
await view.input.press("Enter")
281+
await view.input.pressSequentially("then the tests")
282+
expect(mock.prompts).toEqual([])
283+
await bar.getByRole("button", { name: "Queue", exact: true }).tap()
284+
await expect
285+
.poll(() => mock.prompts.map((prompt) => [prompt.delivery, prompt.text]))
286+
.toEqual([["queue", "check the logs\nthen the tests"]])
287+
288+
await view.input.fill("use the staging database")
289+
await bar.getByRole("button", { name: "Steer", exact: true }).tap()
290+
await expect
291+
.poll(() => mock.prompts.map((prompt) => [prompt.delivery, prompt.text]))
292+
.toEqual([
293+
["queue", "check the logs\nthen the tests"],
294+
["steer", "use the staging database"],
295+
])
296+
await expect(view.input).toBeEmpty()
297+
298+
// Editing a queued prompt: the primary action puts it back in the queue, the other steers it.
299+
await view.rows.filter({ hasText: "check the logs" }).locator('[data-action="session-queue-edit"]').tap()
300+
await expect(view.input).toHaveText(/check the logs/)
301+
await expect(bar.locator('[data-action="composer-alternate-delivery"]')).toHaveText("Steer")
302+
await expect(bar.locator('[data-action="composer-submit"]')).toHaveText("Queue")
303+
})
304+
})
305+
263306
test("a steer shows as pending in the timeline as the composer clears, before the server admits it", async ({
264307
page,
265308
}) => {

0 commit comments

Comments
 (0)