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:
@@ -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)}>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user