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.
This commit is contained in:
Mario Zechner 2026-11-05 20:03:08 +01:00
parent 52e9c17b00
commit e3c8c3b486
2 changed files with 27 additions and 7 deletions

View file

@ -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<HTMLTextAreaElement | null>;
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<HTMLTextAreaElement>(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: (
<div className="session-usage">
<div className="session-controls">

View file

@ -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],
);
});
});