authorgravatar for saphid@gmail.comAlex Southwell <saphid@gmail.com> 2026-10-12 11:46:48+11:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 00:46:48+00:00
logaa2bfc604c538dbaa7e2a7f8698104503497d47a
treed6284fae3a2915ddc457ee2438bd2ad2047500c7
parent7f02b849584332368a5489631d3286409eed90a8
signature Signed by PGP key B5690EEEBB952194

fix(mobile): preserve browser prompt replies across control updates (#18419)

Co-authored-by: SAPHID <saphid@users.noreply.github.com> Co-authored-by: GPT-6.1 Sol <noreply@openai.com>

3 files changed, 65 insertions(+), 2 deletions(-)

apps/mobile/src/features/browser/PreviewStreamWebView.tsx+9-2
......@@ -26,6 +26,7 @@ import { AppText } from "../../components/AppText";
2626import { downloadAndShareAttachment } from "../../lib/attachmentDownload";
2727import { beginForegroundHandoff } from "../../lib/foreground-handoff";
2828import { usePreviewStreamAccess } from "../../state/preview";
29import { previewPromptText } from "./previewPromptText";
2930
3031import {
3132 previewStreamDocument,
......@@ -256,6 +257,7 @@ function PreviewStreamDocumentView({
256257 const [hostSetup, setHostSetup] = useState<PreviewStreamHostSetup | null>(null);
257258 const [started, setStarted] = useState(false);
258259 const [control, setControl] = useState<PreviewStreamControl | null>(null);
260 const dialogRef = useRef<PreviewStreamControl["dialog"]>(null);
259261 const [promptText, setPromptText] = useState("");
260262 const [fileChooser, setFileChooser] = useState<PreviewStreamFileChooser | null>(null);
261263 const answerFileChooser = (pick: boolean) => {
......@@ -371,11 +373,14 @@ function PreviewStreamDocumentView({
371373 const message = previewStreamMessage(event.nativeEvent.data);
372374 if (message === null) return;
373375 switch (message.type) {
374 case "control":
376 case "control": {
375377 setControl(message);
376 setPromptText(message.dialog?.defaultValue ?? "");
378 const previousDialog = dialogRef.current;
379 dialogRef.current = message.dialog;
380 setPromptText((draft) => previewPromptText(previousDialog, message.dialog, draft));
377381 onControl?.(message);
378382 return;
383 }
379384 case "unauthorized":
380385 if (!onUnauthorized()) {
381386 fail("This session can't open the browser stream. Reconnect to try again.");
......@@ -413,6 +418,8 @@ function PreviewStreamDocumentView({
413418 }
414419 setStatus(message.status);
415420 if (message.status === "connecting") {
421 // Dialogs may have closed and reopened while this viewer was disconnected.
422 dialogRef.current = null;
416423 setControl(null);
417424 onControl?.(null);
418425 }
apps/mobile/src/features/browser/previewPromptText.test.ts created+41
......@@ -0,0 +1,41 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { previewPromptText } from "./previewPromptText";
4
5describe("browser prompt response", () => {
6 const prompt = { type: "prompt" as const, message: "Your name?", defaultValue: "Guest" };
7
8 it("preserves a typed response across repeated control broadcasts", () => {
9 expect(previewPromptText(prompt, { ...prompt }, "Alice")).toBe("Alice");
10 });
11
12 it("preserves an intentionally empty response", () => {
13 expect(previewPromptText(prompt, { ...prompt }, "")).toBe("");
14 });
15
16 it("initializes a newly opened prompt", () => {
17 expect(previewPromptText(null, prompt, "")).toBe("Guest");
18 });
19
20 it("resets a stale draft after reconnecting without dialog history", () => {
21 expect(previewPromptText(null, prompt, "Alice")).toBe("Guest");
22 });
23
24 it("resets when the message changes", () => {
25 expect(previewPromptText(prompt, { ...prompt, message: "Next name?" }, "Alice")).toBe("Guest");
26 });
27
28 it("resets when the default value changes", () => {
29 expect(previewPromptText(prompt, { ...prompt, defaultValue: "Bob" }, "Alice")).toBe("Bob");
30 });
31
32 it("resets when the dialog type changes with matching message and default", () => {
33 expect(previewPromptText(prompt, { ...prompt, type: "confirm" }, "Alice")).toBe("Guest");
34 });
35
36 it("clears a closed dialog and initializes an identical reopened prompt", () => {
37 const cleared = previewPromptText(prompt, null, "Alice");
38 expect(cleared).toBe("");
39 expect(previewPromptText(null, prompt, cleared)).toBe("Guest");
40 });
41});
apps/mobile/src/features/browser/previewPromptText.ts created+15
......@@ -0,0 +1,15 @@
1import type { PreviewStreamControl } from "@t3tools/client-runtime/preview/server-browser-stream";
2
3type Dialog = PreviewStreamControl["dialog"];
4
5/** Updates the native response draft when a browser control message arrives. */
6export function previewPromptText(previous: Dialog, next: Dialog, draft: string) {
7 if (
8 next?.type !== previous?.type ||
9 next?.message !== previous?.message ||
10 next?.defaultValue !== previous?.defaultValue
11 ) {
12 return next?.defaultValue ?? "";
13 }
14 return draft;
15}