From e3c8c3b486696783a7fcacc112d0a5201f5e195e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 5 Nov 2026 20:03:08 +0100 Subject: [PATCH] fix(p2): the composer renders its draft at once again Since the one composer (ccff9876e), the screen published the composer's props, the draft included, into the dock a layout effect after each render. The controlled textarea rendered the previous text first after every keystroke: the caret jumped to the end when typing inside the draft, and a phone keyboard's composition broke, so the first tap on Send only closed the keyboard. The composer reads the draft from the app's composer state itself. --- apps/p2/plugins/chat/web.tsx | 12 +++++------- apps/p2/test/p2/ui-mobile-fixes.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/apps/p2/plugins/chat/web.tsx b/apps/p2/plugins/chat/web.tsx index 53a7556b4..6482dfd8d 100644 --- a/apps/p2/plugins/chat/web.tsx +++ b/apps/p2/plugins/chat/web.tsx @@ -265,8 +265,6 @@ function ErrorToast({ error, onDismiss }: { error: string | undefined; onDismiss /** Textarea with send, stop, steer and attachments; Enter sends with a fine pointer, Escape stops. */ function Composer({ - draft, - setDraft, busy, steer, setSteer, @@ -279,8 +277,6 @@ function Composer({ textareaRef, children, }: { - draft: string; - setDraft: (text: string) => void; busy: boolean; steer: boolean; setSteer: (steer: boolean) => void; @@ -295,6 +291,10 @@ function Composer({ textareaRef?: React.RefObject; children?: React.ReactNode; }) { + // The draft straight from the app's composer state, not through the screen's dock content: that arrives a layout + // effect after a keystroke, so the controlled textarea rendered the previous text first, which put the caret at the + // end on every keystroke and broke a phone keyboard's composition (its first tap on Send only closed the keyboard). + const { draft, setDraft } = useComposerState(); const ownRef = useRef(null); const textarea = textareaRef ?? ownRef; // biome-ignore lint/correctness/useExhaustiveDependencies: resizing follows the rendered draft, as in Pi Mobile. @@ -1303,8 +1303,6 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers toBottom, queued: queuedRows, props: { - draft, - setDraft, busy, steer, setSteer, @@ -1575,7 +1573,7 @@ function NewSession() { { composer: owner ? { - props: { draft, setDraft, busy: false, steer, setSteer, disabled: false, onSend: send, attaching }, + props: { busy: false, steer, setSteer, disabled: false, onSend: send, attaching }, footer: (
diff --git a/apps/p2/test/p2/ui-mobile-fixes.test.ts b/apps/p2/test/p2/ui-mobile-fixes.test.ts index cfc7f2859..f05d7674e 100644 --- a/apps/p2/test/p2/ui-mobile-fixes.test.ts +++ b/apps/p2/test/p2/ui-mobile-fixes.test.ts @@ -465,4 +465,26 @@ describe("Pi Mobile audit fixes in the browser", { skip: !browserTests }, () => chat.release(); } }); + + it("keeps the caret where the user types inside the draft (the composer renders the draft it was given at once)", async () => { + const session = await (await holdIn(owner, Sessions, {}, "owner")).create( + { cwd: fixture.workspace, title: "Caret" }, + BACKGROUND_CONTEXT, + ); + await browser.metrics(390, 844); + await browser.open(url(`/s/${session.id}`)); + await browser.wait(`document.querySelector('.composer textarea') && !document.querySelector('.offline')`); + await browser.evaluate(`document.querySelector('.composer textarea').focus()`); + await browser.call("Input.insertText", { text: "hello world" }); + await browser.wait(`document.querySelector('.composer textarea').value === 'hello world'`); + // Put the caret after "hello" and type there, one character at a time as a keyboard does. + await browser.evaluate(`document.querySelector('.composer textarea').setSelectionRange(5, 5)`); + for (const character of " there") await browser.call("Input.insertText", { text: character }); + assert.deepEqual( + await browser.evaluate<[string, number]>( + `(() => { const t = document.querySelector('.composer textarea'); return [t.value, t.selectionStart]; })()`, + ), + ["hello there world", 11], + ); + }); });