fix(collab): isolated editor state to preserve draft input

- Extracted `AskEditor` into a standalone, key-bound component keyed by `reqId`.
- Preserved user-typed draft state across duplicate incoming host requests of the same ID.
- Reset the editor draft state only when a genuinely new request ID arrives.
- Simplified `Composer` autosizing logic and state orchestration by isolating editor-specific hooks.
- Adjusted integration tests to match HTML structure changes of the submit action.
This commit is contained in:
can1357
2026-07-02 02:40:08 +02:00
parent ad4f82eb8f
commit e7be361148
3 changed files with 79 additions and 58 deletions
+1
View File
@@ -5,6 +5,7 @@
### Fixed
- Fixed an issue in the mobile collaboration web UI where 'ask' questions were displayed without response controls.
- Fixed a host re-send of the same editor 'ask' request clobbering a half-typed guest response; the draft now resets only when a new request arrives.
- Fixed the agent transcript drawer hot-retrying forever when the host reports a terminal transcript error (such as an oversized row); the error now stops polling and is shown below any rows already loaded.
- Fixed pre-welcome host `error` frames (such as a protocol-version rejection) being invisible until the welcome timeout; the session now ends immediately with the host's reason.
@@ -1,6 +1,6 @@
import { SendHorizontal, Square } from "lucide-react";
import type { KeyboardEvent, ReactNode } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import type { GuestClient, GuestSnapshot } from "../../lib/client";
export interface ComposerProps {
@@ -13,39 +13,80 @@ const LINE_PX = 20;
const PAD_Y = 16;
const MAX_ROWS = 8;
function optionLabel(option: string | { label: string }): string {
return typeof option === "string" ? option : option.label;
function autosize(el: HTMLTextAreaElement | null): void {
if (!el) return;
el.style.height = "0px";
const max = MAX_ROWS * LINE_PX + PAD_Y;
el.style.height = `${Math.max(LINE_PX + PAD_Y, Math.min(el.scrollHeight, max))}px`;
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
}
interface AskEditorProps {
prefill: string | undefined;
onSubmit(value: string): void;
}
/**
* Editor ask input. Rendered with `key={reqId}` so a new request remounts it with a fresh
* draft seeded from `prefill`, while re-sends of the same request never clobber a half-typed
* draft. Submits verbatim — whitespace-only responses are intentional.
*/
function AskEditor({ prefill, onSubmit }: AskEditorProps): ReactNode {
const [draft, setDraft] = useState(prefill ?? "");
const taRef = useRef<HTMLTextAreaElement | null>(null);
useLayoutEffect(() => {
autosize(taRef.current);
}, [draft]);
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit(draft);
}
};
return (
<div className="sh-composer-inner">
<textarea
ref={taRef}
className="sh-composer-input"
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={onKeyDown}
placeholder="type your response…"
rows={1}
spellCheck={false}
/>
<div className="sh-composer-actions">
<button
type="button"
className="sh-btn sh-btn-primary"
onClick={() => onSubmit(draft)}
title="submit response"
>
<SendHorizontal size={12} /> <span className="sh-btn-label">Submit</span>
</button>
</div>
</div>
);
}
export function Composer({ client, snapshot }: ComposerProps): ReactNode {
const [text, setText] = useState("");
const [uiDraft, setUiDraft] = useState(
snapshot.uiRequest?.kind === "editor" ? (snapshot.uiRequest.prefill ?? "") : "",
);
const taRef = useRef<HTMLTextAreaElement | null>(null);
const live = snapshot.phase === "live";
const readOnly = snapshot.readOnly;
const uiRequest = snapshot.uiRequest;
const uiRequestPrefill = uiRequest?.kind === "editor" ? uiRequest.prefill : undefined;
const canPrompt = live && !readOnly;
const busy = snapshot.working || (snapshot.state?.isStreaming ?? false);
const queued = snapshot.state?.queuedMessageCount ?? 0;
const canSend = canPrompt && text.trim().length > 0;
const canSubmitUiDraft = canPrompt && uiRequest?.kind === "editor";
useEffect(() => {
setUiDraft(uiRequest?.kind === "editor" ? (uiRequestPrefill ?? "") : "");
}, [uiRequest?.reqId, uiRequest?.kind, uiRequestPrefill]);
useLayoutEffect(() => {
const el = taRef.current;
if (!el) return;
el.style.height = "0px";
const max = MAX_ROWS * LINE_PX + PAD_Y;
el.style.height = `${Math.max(LINE_PX + PAD_Y, Math.min(el.scrollHeight, max))}px`;
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
}, [text, uiDraft, uiRequest?.reqId]);
autosize(taRef.current);
}, [text, uiRequest?.reqId]);
const send = useCallback((): void => {
const trimmed = text.trim();
@@ -54,12 +95,6 @@ export function Composer({ client, snapshot }: ComposerProps): ReactNode {
setText("");
}, [client, live, readOnly, text]);
const submitUiDraft = useCallback((): void => {
if (!canPrompt || uiRequest?.kind !== "editor") return;
client.sendUiResponse(uiRequest.reqId, uiDraft);
setUiDraft("");
}, [canPrompt, client, uiDraft, uiRequest]);
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@@ -67,13 +102,6 @@ export function Composer({ client, snapshot }: ComposerProps): ReactNode {
}
};
const onUiKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submitUiDraft();
}
};
if (uiRequest && canPrompt) {
return (
<div className="sh-composer sh-composer-ask">
@@ -81,7 +109,7 @@ export function Composer({ client, snapshot }: ComposerProps): ReactNode {
{uiRequest.kind === "select" ? (
<div className="sh-ask-options">
{uiRequest.options.map((option, index) => {
const label = optionLabel(option);
const label = typeof option === "string" ? option : option.label;
const checked = uiRequest.checkedIndices?.includes(index) ?? false;
return (
<button
@@ -104,29 +132,11 @@ export function Composer({ client, snapshot }: ComposerProps): ReactNode {
})}
</div>
) : (
<div className="sh-composer-inner">
<textarea
ref={taRef}
className="sh-composer-input"
value={uiDraft}
onChange={e => setUiDraft(e.target.value)}
onKeyDown={onUiKeyDown}
placeholder="type your response…"
rows={1}
spellCheck={false}
/>
<div className="sh-composer-actions">
<button
type="button"
className="sh-btn sh-btn-primary"
onClick={submitUiDraft}
disabled={!canSubmitUiDraft}
title="submit response"
>
<SendHorizontal size={12} /> <span className="sh-btn-label">Submit</span>
</button>
</div>
</div>
<AskEditor
key={uiRequest.reqId}
prefill={uiRequest.prefill}
onSubmit={value => client.sendUiResponse(uiRequest.reqId, value)}
/>
)}
<div className="sh-composer-actions sh-ask-actions">
<button type="button" className="sh-btn" onClick={() => client.sendUiResponse(uiRequest.reqId)}>
+12 -2
View File
@@ -63,7 +63,17 @@ describe("Composer host UI requests", () => {
<Composer client={client} snapshot={snapshot({ reqId: 3, kind: "editor", title: "Other", prefill: " " })} />,
);
expect(html).toContain('title="submit response"');
expect(html).not.toMatch(/title="submit response"[^>]*disabled/);
const submit = { found: false, disabled: false };
new HTMLRewriter()
.on('button[title="submit response"]', {
element(el) {
submit.found = true;
submit.disabled = el.hasAttribute("disabled");
},
})
.transform(html);
expect(submit.found).toBe(true);
expect(submit.disabled).toBe(false);
});
});