From 22f707bdb2038699ed07358d49247ba89be9f1fc Mon Sep 17 00:00:00 2001 From: kilhyeonjun Date: Sun, 11 Oct 2026 15:35:50 +0900 Subject: [PATCH 1/7] fix(chat): decouple optional terminal fallback and bound chat reads --- CHANGELOG.md | 1 + docs/development.md | 9 +++ .../terminal-fallback-closed.png | Bin 0 -> 13186 bytes scripts/AGENTS.md | 2 +- scripts/chat-fallback-browser-qa.ts | 69 ++++++++++++++++++ scripts/chat-fallback-fixture.tsx | 14 ++++ scripts/ci-browser.sh | 1 + scripts/ci-browser.test.ts | 3 +- src/AGENTS.md | 2 + src/components/ChatView.tsx | 42 ++++++++++- src/lib/api.ts | 20 +++-- src/lib/chatReadDeadline.test.ts | 25 +++++++ src/lib/chatReadDeadline.ts | 12 +++ src/lib/conversationFetch.test.ts | 25 +++++++ src/lib/i18n.ja.ts | 1 + src/lib/i18n.ko.ts | 1 + src/lib/i18n.zh.ts | 1 + tsconfig.tools.json | 4 +- 18 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 docs/screenshots/chat-fallback/terminal-fallback-closed.png create mode 100644 scripts/chat-fallback-browser-qa.ts create mode 100644 scripts/chat-fallback-fixture.tsx create mode 100644 src/lib/chatReadDeadline.test.ts create mode 100644 src/lib/chatReadDeadline.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3149f2a1..a5897bdab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ([#689](https://github.com/devswha/herdr-web-ui/pull/689) by @zerodice0, [#757](https://github.com/devswha/herdr-web-ui/pull/757)) ### Fixed +- An agent chat without a structured conversation no longer waits on hidden terminal output. Opening the terminal disclosure reads it independently; chat header/body reads have a 15-second deadline. - A pane in use keeps the lens it shows: with **Panes open in → Chat**, a shell split beside an agent no longer turns into its chat when an agent starts in it, and an agent pane no longer turns into its terminal when the agent exits. A lens picked by hand still wins, and a new diff --git a/docs/development.md b/docs/development.md index 45c92691b8..9f5bde41bb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -469,3 +469,12 @@ input regression matrix. | [`docs/`](.) | Remote PCs, updates, flow control, chat audit, brand assets and README media (`docs/media/`) | | [`site/`](../site/) | The website, built by `scripts/build-site.ts` and deployed by GitHub Pages | | [`DESIGN.md`](../DESIGN.md) | Design tokens and UI conventions | + +### Optional terminal fallback + +Agent chats with scrollback fallback finish their conversation read before terminal output is +requested. The disclosure reads only while open and visible, independently of conversation +refresh. Non-agent scrollback stays inline. Conversation and terminal HTTP headers/body share +a 15-second deadline; expired answers cannot update the conversation ETag cache. + +Synthetic browser evidence: [preview](screenshots/chat-fallback/terminal-fallback-closed.png). diff --git a/docs/screenshots/chat-fallback/terminal-fallback-closed.png b/docs/screenshots/chat-fallback/terminal-fallback-closed.png new file mode 100644 index 0000000000000000000000000000000000000000..88dca1fa53c4a7df18b80a98c19d004e653d824a GIT binary patch literal 13186 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 MVE0Gm@7M4D7sFdr1ONa4 literal 0 HcmV?d00001 diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index dd8b1666c6..c7f1119a10 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -1,6 +1,6 @@ # scripts/ -Build, code generation, CI orchestration and the browser QA harness. `tsconfig.tools.json` typechecks the critical check, build, CI and release entrypoints and their imports; `build-xterm.ts` is also included through `vite.config.ts`. Only the `*.test.ts` files are discovered by a test runner, so the rules that decide whether a check actually runs live in this directory. +Build, code generation, CI orchestration and the browser QA harness. `tsconfig.tools.json` typechecks the critical check, build, CI, release and chat-fallback browser entrypoints and their imports; `build-xterm.ts` is also included through `vite.config.ts`. Only the `*.test.ts` files are discovered by a test runner, so the rules that decide whether a check actually runs live in this directory. ## SUITES AND DISCOVERY - `ci-tests.ts` globs `{src,shared,server,scripts,telemetry}/**/*.test.ts` and splits the result into exactly two suites with one predicate: a file needs herdr when it ends in `.contract.test.ts`, or it is `server/updater.test.ts`, or it sits under `server/herdr/` or `server/pty/`. Those last three run in the integration suite despite their plain names. A new test that needs a live herdr must take the `.contract.test.ts` name or be added to that predicate; otherwise it lands in the unit suite, where the socket does not exist. diff --git a/scripts/chat-fallback-browser-qa.ts b/scripts/chat-fallback-browser-qa.ts new file mode 100644 index 0000000000..373f9df9c1 --- /dev/null +++ b/scripts/chat-fallback-browser-qa.ts @@ -0,0 +1,69 @@ +/** Real ChatView with an unavailable conversation and a stalled optional terminal. */ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium } from "playwright-core"; +import { build as viteBuild } from "vite"; +import { serveStatic } from "../server/static.ts"; +const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-chat-fallback-"))); +let browser: Awaited> | undefined; +let server: ReturnType | undefined; +try { + const repo = join(import.meta.dir, ".."), output = join(root, "dist"); + symlinkSync(join(repo, "node_modules"), join(root, "node_modules"), "dir"); + symlinkSync(join(repo, "scripts/chat-fallback-fixture.tsx"), join(root, "fixture.tsx")); + writeFileSync(join(root, "index.html"), '
'); + await viteBuild({ configFile: join(repo, "vite.config.ts"), root, publicDir: false, logLevel: "warn", build: { outDir: output, emptyOutDir: true } }); + const policy = (await serveStatic("/")).headers.get("content-security-policy"); + assert.ok(policy, "build the app before its enforcing CSP regression"); + const terminalReads: string[] = []; + let structured = false, resolveAgent!: () => void; + server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { + const url = new URL(request.url), pane = url.searchParams.get("pane_id"); + if (url.pathname === "/api/pane/prompt") return Response.json({ prompt: null }); + if (url.pathname === "/api/pane/conversation") return Response.json({ source: structured && pane === "agent" ? "codex-transcript" : "scrollback", cursor: null, turns: structured && pane === "agent" ? [{ role: "assistant", ts: null, parts: [{ kind: "text", text: "Structured conversation arrived" }] }] : [] }); + if (url.pathname === "/api/pane/read") { + terminalReads.push(pane ?? ""); + const response = () => Response.json({ read: { text: pane === "shell" ? "Shell inline output" : "OLD AGENT TERMINAL", truncated: false } }); + if (pane === "agent") return new Promise((resolve) => { resolveAgent = () => resolve(response()); }); + return response(); + } + if (url.pathname === "/favicon.ico") return new Response(null, { status: 204 }); + return new Response(Bun.file(join(output, url.pathname === "/" ? "index.html" : url.pathname.slice(1))), { headers: { "content-security-policy": policy } }); + } }); + browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); + const page = await browser.newPage(); page.setDefaultTimeout(10_000); + await page.addInitScript(() => localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en" }))); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.port}`); + const summary = page.locator(".chat-terminal-fallback summary"); + await summary.waitFor(); + await page.getByText("Loading conversation…", { exact: true }).waitFor({ state: "detached" }); + assert.equal(terminalReads.length, 0, "a hidden terminal read cannot hold the initial chat"); + if (process.env.UI_EVIDENCE_DIR) { + mkdirSync(process.env.UI_EVIDENCE_DIR, { recursive: true }); + await page.screenshot({ path: join(process.env.UI_EVIDENCE_DIR, "terminal-fallback-closed.png") }); + } + const terminal = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/pane/read"); + await summary.click(); await terminal; + assert.ok(terminalReads.includes("agent")); + structured = true; + await page.evaluate(() => window.chatFallbackQA.refresh()); + await page.getByText("Structured conversation arrived", { exact: true }).waitFor(); + const nextKindRead = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/pane/conversation", { timeout: 3_000 }); + await page.evaluate(() => window.chatFallbackQA.agent("claude")); + await nextKindRead; + resolveAgent(); + await page.locator(".chat-terminal-fallback").waitFor({ state: "detached" }); + assert.ok(!(await page.locator("body").innerText()).includes("OLD AGENT TERMINAL")); + await page.evaluate(() => window.chatFallbackQA.pane("shell")); + await page.getByText("Shell inline output", { exact: true }).waitFor(); + assert.ok(terminalReads.includes("shell"), "non-agent scrollback remains inline"); + assert.deepEqual(errors, []); + console.log("chat fallback: browser OK (no hidden terminal reads, opened stalled output cannot block structured refresh, late cleanup and non-agent inline output)"); + +} finally { + await browser?.close(); server?.stop(true); rmSync(root, { recursive: true, force: true }); +} diff --git a/scripts/chat-fallback-fixture.tsx b/scripts/chat-fallback-fixture.tsx new file mode 100644 index 0000000000..a1e3201c86 --- /dev/null +++ b/scripts/chat-fallback-fixture.tsx @@ -0,0 +1,14 @@ +import React, { useState } from "react"; +import { createRoot } from "react-dom/client"; +import "../src/styles.css"; +import { ChatView } from "../src/components/ChatView.tsx"; +import { SettingsProvider } from "../src/lib/settings.ts"; +declare global { interface Window { chatFallbackQA: { pane: (pane: string) => void; refresh: () => void; agent: (agent: string | null) => void } } } +function Fixture() { + const [agent, setAgent] = useState("codex"); + const [pane, setPane] = useState("agent"); + const [refresh, setRefresh] = useState(0); + window.chatFallbackQA = { pane: setPane, agent: setAgent, refresh: () => setRefresh((key) => key + 1) }; + return
; +} +createRoot(document.getElementById("root")!).render(); diff --git a/scripts/ci-browser.sh b/scripts/ci-browser.sh index ab3d17b566..4a8b36a1d1 100644 --- a/scripts/ci-browser.sh +++ b/scripts/ci-browser.sh @@ -62,6 +62,7 @@ run_script scripts/key-bar-hold-repeat-regression.ts run_script scripts/settings-pages-demo-regression.ts run_script scripts/deep-link-demo-regression.ts run_script scripts/chat-history-browser-qa.ts +run_script scripts/chat-fallback-browser-qa.ts run_script scripts/math-browser-qa.ts run_script scripts/file-viewer-regression.ts run_script scripts/keyboard-viewport-regression.ts diff --git a/scripts/ci-browser.test.ts b/scripts/ci-browser.test.ts index 28190beb25..33998492cc 100644 --- a/scripts/ci-browser.test.ts +++ b/scripts/ci-browser.test.ts @@ -59,8 +59,9 @@ describe("browser lane", () => { it("summarizes every script and exits zero on success without enabling local evidence", async () => { const { text, code, evidence } = await lane(); expect(code).toBe(0); - expect(rows(text)).toHaveLength(29); + expect(rows(text)).toHaveLength(30); expect(rows(text).map((row) => row.script)).toContain("scripts/pending-pane-switch-regression.ts"); + expect(rows(text).map((row) => row.script)).toContain("scripts/chat-fallback-browser-qa.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/terminal-viewport-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/palette-machines-demo-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/restored-draft-demo-regression.ts"); diff --git a/src/AGENTS.md b/src/AGENTS.md index ea89bc463f..41d5f711f7 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -71,3 +71,5 @@ React 18 + xterm.js browser client. The render-state machine, xterm lifecycle an | Styling | styles.css + colocated .css | styles.css = `:root` tokens (+ `[data-theme="light"]`, `[data-density="compact"]`) + shell + shared primitives (`.icon-button .btn .pill .badge .segmented .kbd .modal .field .input .select .menu`); component looks live in their own .css; tokens only — no color literals | | Icons | lucide-react | the one icon set; AgentMark.tsx keeps the provider brand marks | | Workspace drawer / a11y | App.tsx + components/SidebarResizer.tsx + lib/sidebarWidth.ts | from 769px the sidebar's right edge resizes it (drag, arrows, Home/End, double-click resets); the width is per device (`herdr-web-ui:sidebar-width`) and reaches CSS as `--sidebar-user-w` on `.app`, read through `--sidebar-shown-w` by `.sidebar` and `.header-side` only, so dialogs sized from `--sidebar-w` do not move; toggle is `aria-expanded` + `aria-controls="workspace-drawer"`; scrim closes on tap; conn pill and banners are live regions; the conn pill is drawn only while not live and stays in the document (`.conn-live`, 1px, never `display: none`: the browser scripts wait on it) | + +- Chat read deadlines cover headers and body; apply conversation cache changes only after the bounded read succeeds. Agent terminal fallback reads run independently after the disclosure opens. diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index db6520c7a6..21a57e53c8 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -543,6 +543,41 @@ function FallbackTurn({ paneId, message }: { paneId: string; message: Transcript return ; } +/** Optional terminal output must never hold initial display or transcript polling. */ +function TerminalFallback({ paneId }: { paneId: string }) { + const { fetchPaneTranscript } = useMachineApi(); + const t = useT(); + const [open, setOpen] = useState(false); + const [text, setText] = useState(null); + const [error, setError] = useState(null); + const visible = usePageVisible(); + useEffect(() => { + if (!open || !visible) return; + let cancelled = false; + let timer: number | undefined; + const read = async (): Promise => { + try { + const result = await fetchPaneTranscript(paneId, TRANSCRIPT_LINES); + if (cancelled) return; + setText(toTranscriptMessages(result.text).filter((message) => message.role !== "status").map((message) => message.text).join("\n\n")); + setError(null); + } catch (cause) { + if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + if (!cancelled) timer = window.setTimeout(() => void read(), PROMPT_POLL_MS); + } + }; + void read(); + return () => { cancelled = true; window.clearTimeout(timer); }; + }, [paneId, open, visible, fetchPaneTranscript]); + return
setOpen(event.currentTarget.open)}> + {t("Conversation unavailable — show terminal output")} + {text !== null &&
{text}
} + {text === null && error === null &&

{t("Loading…")}

} + {error !== null &&

{error}

} +
; +} + // the app re-renders on every pane-status and poll; an unchanged transcript sits those out export const ChatView = memo(function ChatView({ paneId, refreshKey, invalidationKey = 0, onConversationWatch, conversationPushes = false, onTerminalOutput, sentKey = 0, connected, ended, agent, agentStatus, onMetadata, onRead, greeted = false, onPrompt, onSuggestion, onRestored, promptRefreshKey = 0, pendingAnswer = null, onPendingAnswerDone, promptDock = null, onPromptAnswered }: ChatViewProps) { const t = useT(); @@ -708,6 +743,7 @@ export const ChatView = memo(function ChatView({ paneId, refreshKey, invalidatio setAbandoned(conversation.abandoned ?? null); let next: ChatState; if (conversation.source !== "scrollback") next = { source: "conversation", turns: conversation.turns, messages: [], truncated: false }; + else if (agent !== null) next = { source: "scrollback", turns: [], messages: [], truncated: false }; else { const result = await fetchPaneTranscript(paneId, TRANSCRIPT_LINES); if (cancelled || generation !== olderGeneration.current) return; @@ -740,13 +776,13 @@ export const ChatView = memo(function ChatView({ paneId, refreshKey, invalidatio onConversationWatch?.(paneId, false); conversationRefresh.stop(); }; - }, [paneId, fetchPaneConversation, fetchPaneTranscript, conversationRefresh, visible, onConversationWatch]); + }, [paneId, fetchPaneConversation, fetchPaneTranscript, conversationRefresh, visible, onConversationWatch, agent]); // Sends, status/history repairs and visibility return read immediately without cancelling // the answer in flight. The reader is installed before this effect requests its first read. useEffect(() => { if (visible) conversationRefresh.refresh(); - }, [paneId, refreshKey, pollKey, visible, fetchPaneConversation, fetchPaneTranscript, conversationRefresh, onConversationWatch]); + }, [paneId, refreshKey, pollKey, visible, fetchPaneConversation, fetchPaneTranscript, conversationRefresh, onConversationWatch, agent]); // Mount/visibility return already read immediately; only a changed push key invalidates. useEffect(() => { @@ -978,7 +1014,7 @@ export const ChatView = memo(function ChatView({ paneId, refreshKey, invalidatio ; }) : agent !== null - ?
{t("Conversation unavailable — show terminal output")}
{state.messages.map((message) => message.text).join("\n\n")}
+ ? : state.messages.map((message, index) => )} {!ended && !connected &&

{t("Reconnecting…")}

} {error !== null &&

{errorStatus === 401 ? t("locked — the token gate is asking again") : error}

} diff --git a/src/lib/api.ts b/src/lib/api.ts index aa80850486..51ccee5f87 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -60,6 +60,7 @@ import type { TelemetryStatus } from "../../shared/telemetry.ts"; import type { VoiceConfigUpdate, VoiceStatus } from "../../shared/voice.ts"; import { MAX_ATTACHMENT_BYTES } from "../../shared/attachments.ts"; import { t } from "./i18n.ts"; +import { withChatReadDeadline } from "./chatReadDeadline.ts"; /** Settings → Phone: what Tailscale on the server's PC already serves, or the command to run. */ export function fetchRemoteAccess(): Promise { @@ -155,8 +156,8 @@ async function errorFrom(url: string, response: Response): Promise { return new ApiError(url, response.status, detail, code); } -async function getJson(url: string): Promise { - const response = await fetch(url); +async function getJson(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init); if (!response.ok) throw await errorFrom(url, response); return (await response.json()) as T; } @@ -179,7 +180,7 @@ const wrappedOnly = new Set(); export async function fetchPaneTranscript(paneId: string, lines: number, machineId = "local"): Promise { const read = async (source: "recent" | "recent_unwrapped"): Promise => { const query = new URLSearchParams({ pane_id: paneId, source, format: "text", lines: String(lines) }); - return (await getJson<{ read: PaneReadResult }>(machinePath(machineId, `pane/read?${query.toString()}`))).read; + return (await withChatReadDeadline((signal) => getJson<{ read: PaneReadResult }>(machinePath(machineId, `pane/read?${query.toString()}`), { signal }))).read; }; if (wrappedOnly.has(machineId)) return read("recent"); try { @@ -250,15 +251,18 @@ export async function fetchPaneConversation(paneId: string, machineId = "local", const url = machinePath(machineId, `pane/conversation?${query.toString()}`); const polled = page.before === undefined; const known = polled ? conversationAnswers.get(url) : undefined; - const response = await fetch(url, { cache: "no-store", ...(known ? { headers: { "if-none-match": known.etag } } : {}) }); + const { response, body } = await withChatReadDeadline(async (signal) => { + const response = await fetch(url, { cache: "no-store", signal, ...(known ? { headers: { "if-none-match": known.etag } } : {}) }); + if (response.status === 304 && known) return { response, body: known.body }; + if (!response.ok) throw await errorFrom(url, response); + return { response, body: (await response.json()) as ConversationResponse }; + }); + // Cache changes occur after the bounded read: a late, aborted answer cannot replace it. if (response.status === 304 && known) { - // the pane being polled stays among the kept answers conversationAnswers.delete(url); conversationAnswers.set(url, known); - return known.body; + return body; } - if (!response.ok) throw await errorFrom(url, response); - const body = (await response.json()) as ConversationResponse; const etag = response.headers.get("etag"); if (!polled) return body; conversationAnswers.delete(url); diff --git a/src/lib/chatReadDeadline.test.ts b/src/lib/chatReadDeadline.test.ts new file mode 100644 index 0000000000..a8d9e10866 --- /dev/null +++ b/src/lib/chatReadDeadline.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { withChatReadDeadline } from "./chatReadDeadline.ts"; + +test("a stalled header aborts and releases the serialized reader", async () => { + let signal: AbortSignal | undefined; + await expect(withChatReadDeadline((next) => { signal = next; return new Promise(() => {}); }, 10)).rejects.toThrow("timed out"); + expect(signal?.aborted).toBe(true); + expect(await withChatReadDeadline(async () => "next read", 10)).toBe("next read"); +}); + +test("the same deadline covers the body and ignores a late result", async () => { + let resolve!: (value: string) => void; + const late = new Promise((done) => { resolve = done; }); + const read = withChatReadDeadline(async () => { await Promise.resolve("headers"); return await late; }, 10); + await expect(read).rejects.toThrow("timed out"); + resolve("obsolete"); + expect(await withChatReadDeadline(async () => "current", 10)).toBe("current"); +}); + +test("success and HTTP failures preserve the original result or error", async () => { + const error = new Error("HTTP 401"); + await expect(withChatReadDeadline(async () => { throw error; }, 10)).rejects.toBe(error); + const result = { turns: [] }; + expect(await withChatReadDeadline(async () => result, 10)).toBe(result); +}); diff --git a/src/lib/chatReadDeadline.ts b/src/lib/chatReadDeadline.ts new file mode 100644 index 0000000000..ec6ddbf522 --- /dev/null +++ b/src/lib/chatReadDeadline.ts @@ -0,0 +1,12 @@ +import { t } from "./i18n.ts"; + +/** Covers headers and body; even an implementation ignoring abort cannot hold the reader. */ +export async function withChatReadDeadline(read: (signal: AbortSignal) => Promise, timeoutMs = 15_000): Promise { + const controller = new AbortController(); + let timer: ReturnType; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { reject(new Error(t("Chat read timed out."))); controller.abort(); }, timeoutMs); + }); + try { return await Promise.race([read(controller.signal), deadline]); } + finally { clearTimeout(timer!); } +} diff --git a/src/lib/conversationFetch.test.ts b/src/lib/conversationFetch.test.ts index bfd4d6d976..3433f36b16 100644 --- a/src/lib/conversationFetch.test.ts +++ b/src/lib/conversationFetch.test.ts @@ -493,3 +493,28 @@ describe("conversation invalidation refresh", () => { expect(clock.pending()).toBe(0); }); }); + +it("a timed-out JSON body cannot replace the last conversation ETag, even when it answers late", async () => { + const originalTimer = globalThis.setTimeout; + const clock = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void, delay: number) => originalTimer(callback, delay === 15_000 ? 10 : delay)) as typeof setTimeout); + try { + const pane = "deadline-cache:p1"; + globalThis.fetch = (async (_url: string) => Response.json({ source: "codex-transcript", turns: [], cursor: null }, { headers: { etag: '"current"' } })) as typeof fetch; + const current = await fetchPaneConversation(pane); + let resolve!: (body: ConversationResponse) => void; + const late = new Promise((done) => { resolve = done; }); + const lateResponse = Response.json({}, { headers: { etag: '"obsolete"' } }); + lateResponse.json = () => late; + globalThis.fetch = (async (_url: string) => lateResponse) as typeof fetch; + await expect(fetchPaneConversation(pane)).rejects.toThrow("timed out"); + resolve({ source: "codex-transcript", turns: [], cursor: null }); + await Promise.resolve(); + const asked: (string | null)[] = []; + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + asked.push(new Headers(init?.headers).get("if-none-match")); + return new Response(null, { status: 304 }); + }) as typeof fetch; + expect(await fetchPaneConversation(pane)).toBe(current); + expect(asked).toEqual(['"current"']); + } finally { clock.mockRestore(); } +}); diff --git a/src/lib/i18n.ja.ts b/src/lib/i18n.ja.ts index a3ac013e53..39e1ecfa2f 100644 --- a/src/lib/i18n.ja.ts +++ b/src/lib/i18n.ja.ts @@ -8,6 +8,7 @@ * in English. Placeholders in braces keep their English names. */ export const JA: Record = { + "Chat read timed out.": "会話の読み込みがタイムアウトしました。", "Find in terminal": "ターミナル内を検索", "Previous match": "前の一致", "Next match": "次の一致", diff --git a/src/lib/i18n.ko.ts b/src/lib/i18n.ko.ts index 3ef042fb68..7cbae19207 100644 --- a/src/lib/i18n.ko.ts +++ b/src/lib/i18n.ko.ts @@ -6,6 +6,7 @@ * braces keep their English names. */ export const KO: Record = { + "Chat read timed out.": "대화 읽기 시간이 초과되었습니다.", "Find in terminal": "터미널에서 찾기", "Previous match": "이전 결과", "Next match": "다음 결과", diff --git a/src/lib/i18n.zh.ts b/src/lib/i18n.zh.ts index 474fbe6ae7..0aa0488606 100644 --- a/src/lib/i18n.zh.ts +++ b/src/lib/i18n.zh.ts @@ -10,6 +10,7 @@ * Placeholders in braces keep their English names. */ export const ZH: Record = { + "Chat read timed out.": "读取对话超时。", "Find in terminal": "在终端中查找", "Previous match": "上一个匹配", "Next match": "下一个匹配", diff --git a/tsconfig.tools.json b/tsconfig.tools.json index 84dee8b68f..71de277a64 100644 --- a/tsconfig.tools.json +++ b/tsconfig.tools.json @@ -12,7 +12,9 @@ "site/demo/transport.ts", "scripts/build-remote-bundle.ts", "scripts/ssh-integration.ts", - "scripts/file-viewer-regression.ts" + "scripts/file-viewer-regression.ts", + "scripts/chat-fallback-browser-qa.ts", + "scripts/chat-fallback-fixture.tsx" ], "include": [] } From a1ff9a390061fb840da416d178e56b4c405517d2 Mon Sep 17 00:00:00 2001 From: kilhyeonjun Date: Sun, 11 Oct 2026 16:22:04 +0900 Subject: [PATCH 2/7] fix(chat): clarify fallback state and announce terminal errors --- CHANGELOG.md | 3 ++- .../terminal-fallback-closed.png | Bin 13186 -> 8423 bytes scripts/chat-fallback-browser-qa.ts | 16 ++++++++++++++-- src/components/ChatView.tsx | 4 ++-- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5897bdab0..4c077f4e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,6 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ([#689](https://github.com/devswha/herdr-web-ui/pull/689) by @zerodice0, [#757](https://github.com/devswha/herdr-web-ui/pull/757)) ### Fixed -- An agent chat without a structured conversation no longer waits on hidden terminal output. Opening the terminal disclosure reads it independently; chat header/body reads have a 15-second deadline. - A pane in use keeps the lens it shows: with **Panes open in → Chat**, a shell split beside an agent no longer turns into its chat when an agent starts in it, and an agent pane no longer turns into its terminal when the agent exits. A lens picked by hand still wins, and a new @@ -59,6 +58,8 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa the card stays up with the cursor on its **Submit** row. A pick there used to untick the row instead of answering. ([#764](https://github.com/devswha/herdr-web-ui/pull/764)) +- An agent chat without a structured conversation no longer waits on hidden terminal output. Opening the terminal disclosure reads it independently; chat header/body reads have a 15-second deadline. + ([#791](https://github.com/devswha/herdr-web-ui/pull/791) by @kilhyeonjun) - A Claude `/model` list whose hint a narrow pane wraps inside a word or over more than six lines is still recognised, so a pending message waits instead of pressing Enter on it, and the same words quoted in Claude's output no longer hold pending messages. diff --git a/docs/screenshots/chat-fallback/terminal-fallback-closed.png b/docs/screenshots/chat-fallback/terminal-fallback-closed.png index 88dca1fa53c4a7df18b80a98c19d004e653d824a..4de175f9688c51e9e2c3a607f02be615dac0c4b3 100644 GIT binary patch literal 8423 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 literal 13186 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 MVE0Gm@7M4D7sFdr1ONa4 diff --git a/scripts/chat-fallback-browser-qa.ts b/scripts/chat-fallback-browser-qa.ts index 373f9df9c1..b3fa316503 100644 --- a/scripts/chat-fallback-browser-qa.ts +++ b/scripts/chat-fallback-browser-qa.ts @@ -22,9 +22,10 @@ try { server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { const url = new URL(request.url), pane = url.searchParams.get("pane_id"); if (url.pathname === "/api/pane/prompt") return Response.json({ prompt: null }); - if (url.pathname === "/api/pane/conversation") return Response.json({ source: structured && pane === "agent" ? "codex-transcript" : "scrollback", cursor: null, turns: structured && pane === "agent" ? [{ role: "assistant", ts: null, parts: [{ kind: "text", text: "Structured conversation arrived" }] }] : [] }); + if (url.pathname === "/api/pane/conversation") return Response.json({ source: (structured && pane === "agent") || pane === "empty" ? "codex-transcript" : "scrollback", cursor: null, turns: structured && pane === "agent" ? [{ role: "assistant", ts: null, parts: [{ kind: "text", text: "Structured conversation arrived" }] }] : [] }); if (url.pathname === "/api/pane/read") { terminalReads.push(pane ?? ""); + if (pane === "error") return Response.json({ error: { code: "read_failed", message: "Terminal output unavailable" } }, { status: 500 }); const response = () => Response.json({ read: { text: pane === "shell" ? "Shell inline output" : "OLD AGENT TERMINAL", truncated: false } }); if (pane === "agent") return new Promise((resolve) => { resolveAgent = () => resolve(response()); }); return response(); @@ -42,6 +43,7 @@ try { await summary.waitFor(); await page.getByText("Loading conversation…", { exact: true }).waitFor({ state: "detached" }); assert.equal(terminalReads.length, 0, "a hidden terminal read cannot hold the initial chat"); + assert.equal(await page.locator(".chat-empty").count(), 0, "an agent terminal fallback does not claim the conversation is empty"); if (process.env.UI_EVIDENCE_DIR) { mkdirSync(process.env.UI_EVIDENCE_DIR, { recursive: true }); await page.screenshot({ path: join(process.env.UI_EVIDENCE_DIR, "terminal-fallback-closed.png") }); @@ -61,8 +63,18 @@ try { await page.evaluate(() => window.chatFallbackQA.pane("shell")); await page.getByText("Shell inline output", { exact: true }).waitFor(); assert.ok(terminalReads.includes("shell"), "non-agent scrollback remains inline"); + await page.evaluate(() => window.chatFallbackQA.pane("error")); + await summary.waitFor(); + await summary.click(); + const fallbackError = page.getByText("Terminal output unavailable"); + await fallbackError.waitFor(); + assert.equal(await fallbackError.getAttribute("role"), "alert", "terminal-read failures are announced as errors"); + assert.equal(await page.locator(".chat-empty").count(), 0, "a fallback failure is not an empty structured conversation"); + await page.evaluate(() => window.chatFallbackQA.pane("empty")); + await page.getByText("No conversation yet — say something below", { exact: true }).waitFor(); + assert.equal(await page.locator(".chat-terminal-fallback").count(), 0, "an empty structured conversation keeps its ordinary prompt"); assert.deepEqual(errors, []); - console.log("chat fallback: browser OK (no hidden terminal reads, opened stalled output cannot block structured refresh, late cleanup and non-agent inline output)"); + console.log("chat fallback: browser OK (no hidden terminal reads, opened stalled output cannot block structured refresh, late cleanup, non-agent inline output, fallback error announcement and accurate empty state)"); } finally { await browser?.close(); server?.stop(true); rmSync(root, { recursive: true, force: true }); diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 21a57e53c8..ea77fc4b31 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -574,7 +574,7 @@ function TerminalFallback({ paneId }: { paneId: string }) { {t("Conversation unavailable — show terminal output")} {text !== null &&
{text}
} {text === null && error === null &&

{t("Loading…")}

} - {error !== null &&

{error}

} + {error !== null &&

{error}

} ; } @@ -970,7 +970,7 @@ export const ChatView = memo(function ChatView({ paneId, refreshKey, invalidatio }, [turns, restored]); heldPage.current = state.turns; const finishedBeforeSend = sentOver !== null && sentOver.page === state.turns ? sentOver.turn : null; - const empty = state.source === "conversation" ? turns.length === 0 : state.messages.length === 0; + const empty = state.source === "conversation" ? turns.length === 0 : agent === null && state.messages.length === 0; const blank = !ended && chatIsBlank({ loaded, failed: error !== null, transcript: state.source === "conversation", turns: turns.length, older: typeof olderCursor === "string", abandoned: abandoned?.count ?? 0, prompt: prompt !== null, agent }); // the scrollback standing in says nothing of the conversation: it has no history to compare const known = loaded && error === null && !ended && state.source === "conversation"; From dd27a07a37d9c218c82d618c042888c0f60edc5c Mon Sep 17 00:00:00 2001 From: Hako Date: Sun, 11 Oct 2026 16:22:41 +0900 Subject: [PATCH 3/7] fix(chat): no empty-chat hint behind the terminal disclosure, alert on its error, deterministic browser QA wait Co-Authored-By: Claude Opus 5.5 --- scripts/chat-fallback-browser-qa.ts | 13 +++++++++---- src/components/ChatView.tsx | 5 +++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/chat-fallback-browser-qa.ts b/scripts/chat-fallback-browser-qa.ts index 373f9df9c1..62ec8aa90d 100644 --- a/scripts/chat-fallback-browser-qa.ts +++ b/scripts/chat-fallback-browser-qa.ts @@ -18,7 +18,9 @@ try { const policy = (await serveStatic("/")).headers.get("content-security-policy"); assert.ok(policy, "build the app before its enforcing CSP regression"); const terminalReads: string[] = []; - let structured = false, resolveAgent!: () => void; + let structured = false, resolveAgent!: () => void, agentReadArrived!: () => void; + // resolved by the server's handler: a browser-side waitForRequest can win the race against it + const agentRead = new Promise((resolve) => { agentReadArrived = resolve; }); server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { const url = new URL(request.url), pane = url.searchParams.get("pane_id"); if (url.pathname === "/api/pane/prompt") return Response.json({ prompt: null }); @@ -26,7 +28,7 @@ try { if (url.pathname === "/api/pane/read") { terminalReads.push(pane ?? ""); const response = () => Response.json({ read: { text: pane === "shell" ? "Shell inline output" : "OLD AGENT TERMINAL", truncated: false } }); - if (pane === "agent") return new Promise((resolve) => { resolveAgent = () => resolve(response()); }); + if (pane === "agent") return new Promise((resolve) => { resolveAgent = () => resolve(response()); agentReadArrived(); }); return response(); } if (url.pathname === "/favicon.ico") return new Response(null, { status: 204 }); @@ -42,12 +44,15 @@ try { await summary.waitFor(); await page.getByText("Loading conversation…", { exact: true }).waitFor({ state: "detached" }); assert.equal(terminalReads.length, 0, "a hidden terminal read cannot hold the initial chat"); + assert.equal(await page.locator(".chat-empty").count(), 0, "the terminal disclosure is no empty conversation"); if (process.env.UI_EVIDENCE_DIR) { mkdirSync(process.env.UI_EVIDENCE_DIR, { recursive: true }); await page.screenshot({ path: join(process.env.UI_EVIDENCE_DIR, "terminal-fallback-closed.png") }); } - const terminal = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/pane/read"); - await summary.click(); await terminal; + await summary.click(); + let deadline: ReturnType | undefined; + await Promise.race([agentRead, new Promise((_, reject) => { deadline = setTimeout(() => reject(new Error("opening the disclosure never read the agent terminal")), 10_000); })]); + clearTimeout(deadline); assert.ok(terminalReads.includes("agent")); structured = true; await page.evaluate(() => window.chatFallbackQA.refresh()); diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 21a57e53c8..f827ef07e0 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -574,7 +574,7 @@ function TerminalFallback({ paneId }: { paneId: string }) { {t("Conversation unavailable — show terminal output")} {text !== null &&
{text}
} {text === null && error === null &&

{t("Loading…")}

} - {error !== null &&

{error}

} + {error !== null &&

{error}

} ; } @@ -970,7 +970,8 @@ export const ChatView = memo(function ChatView({ paneId, refreshKey, invalidatio }, [turns, restored]); heldPage.current = state.turns; const finishedBeforeSend = sentOver !== null && sentOver.page === state.turns ? sentOver.turn : null; - const empty = state.source === "conversation" ? turns.length === 0 : state.messages.length === 0; + // an agent's scrollback stands behind the terminal disclosure, which is no empty conversation + const empty = state.source === "conversation" ? turns.length === 0 : agent === null && state.messages.length === 0; const blank = !ended && chatIsBlank({ loaded, failed: error !== null, transcript: state.source === "conversation", turns: turns.length, older: typeof olderCursor === "string", abandoned: abandoned?.count ?? 0, prompt: prompt !== null, agent }); // the scrollback standing in says nothing of the conversation: it has no history to compare const known = loaded && error === null && !ended && state.source === "conversation"; From b4df8472ab1bcd5de309bd28160067cc65ea4d72 Mon Sep 17 00:00:00 2001 From: Hako Date: Sun, 11 Oct 2026 16:23:10 +0900 Subject: [PATCH 4/7] test(ci): count the browser lane's row from main's merged script Co-Authored-By: Claude Opus 5.5 --- scripts/ci-browser.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci-browser.test.ts b/scripts/ci-browser.test.ts index 33998492cc..ffef294209 100644 --- a/scripts/ci-browser.test.ts +++ b/scripts/ci-browser.test.ts @@ -59,7 +59,7 @@ describe("browser lane", () => { it("summarizes every script and exits zero on success without enabling local evidence", async () => { const { text, code, evidence } = await lane(); expect(code).toBe(0); - expect(rows(text)).toHaveLength(30); + expect(rows(text)).toHaveLength(31); expect(rows(text).map((row) => row.script)).toContain("scripts/pending-pane-switch-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/chat-fallback-browser-qa.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/terminal-viewport-regression.ts"); From 992aae65ba0f4a95d7ea642aca7cb9d86a6ff63f Mon Sep 17 00:00:00 2001 From: Hako Date: Sun, 11 Oct 2026 17:16:14 +0900 Subject: [PATCH 5/7] fix(chat): give remote-PC chat reads a 30-second deadline Local chat reads keep 15 seconds; a remote PC's read crosses the machine proxy and gets 30. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/lib/api.ts | 6 +++--- src/lib/chatReadDeadline.test.ts | 7 ++++++- src/lib/chatReadDeadline.ts | 6 ++++++ 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f14f8695bc..dd591e0b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,7 +65,7 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa the card stays up with the cursor on its **Submit** row. A pick there used to untick the row instead of answering. ([#764](https://github.com/devswha/herdr-web-ui/pull/764)) -- An agent chat without a structured conversation no longer waits on hidden terminal output. Opening the terminal disclosure reads it independently; chat header/body reads have a 15-second deadline. +- An agent chat without a structured conversation no longer waits on hidden terminal output. Opening the terminal disclosure reads it independently; chat header/body reads have a 15-second deadline (30 seconds on a remote PC). ([#791](https://github.com/devswha/herdr-web-ui/pull/791) by @kilhyeonjun) - A Claude `/model` list whose hint a narrow pane wraps inside a word or over more than six lines is still recognised, so a pending message waits instead of pressing Enter on it, and the same diff --git a/src/lib/api.ts b/src/lib/api.ts index 51ccee5f87..2c0f7144e8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -60,7 +60,7 @@ import type { TelemetryStatus } from "../../shared/telemetry.ts"; import type { VoiceConfigUpdate, VoiceStatus } from "../../shared/voice.ts"; import { MAX_ATTACHMENT_BYTES } from "../../shared/attachments.ts"; import { t } from "./i18n.ts"; -import { withChatReadDeadline } from "./chatReadDeadline.ts"; +import { chatReadDeadlineMs, withChatReadDeadline } from "./chatReadDeadline.ts"; /** Settings → Phone: what Tailscale on the server's PC already serves, or the command to run. */ export function fetchRemoteAccess(): Promise { @@ -180,7 +180,7 @@ const wrappedOnly = new Set(); export async function fetchPaneTranscript(paneId: string, lines: number, machineId = "local"): Promise { const read = async (source: "recent" | "recent_unwrapped"): Promise => { const query = new URLSearchParams({ pane_id: paneId, source, format: "text", lines: String(lines) }); - return (await withChatReadDeadline((signal) => getJson<{ read: PaneReadResult }>(machinePath(machineId, `pane/read?${query.toString()}`), { signal }))).read; + return (await withChatReadDeadline((signal) => getJson<{ read: PaneReadResult }>(machinePath(machineId, `pane/read?${query.toString()}`), { signal }), chatReadDeadlineMs(machineId))).read; }; if (wrappedOnly.has(machineId)) return read("recent"); try { @@ -256,7 +256,7 @@ export async function fetchPaneConversation(paneId: string, machineId = "local", if (response.status === 304 && known) return { response, body: known.body }; if (!response.ok) throw await errorFrom(url, response); return { response, body: (await response.json()) as ConversationResponse }; - }); + }, chatReadDeadlineMs(machineId)); // Cache changes occur after the bounded read: a late, aborted answer cannot replace it. if (response.status === 304 && known) { conversationAnswers.delete(url); diff --git a/src/lib/chatReadDeadline.test.ts b/src/lib/chatReadDeadline.test.ts index a8d9e10866..4957cd5794 100644 --- a/src/lib/chatReadDeadline.test.ts +++ b/src/lib/chatReadDeadline.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { withChatReadDeadline } from "./chatReadDeadline.ts"; +import { chatReadDeadlineMs, withChatReadDeadline } from "./chatReadDeadline.ts"; test("a stalled header aborts and releases the serialized reader", async () => { let signal: AbortSignal | undefined; @@ -23,3 +23,8 @@ test("success and HTTP failures preserve the original result or error", async () const result = { turns: [] }; expect(await withChatReadDeadline(async () => result, 10)).toBe(result); }); + +test("a remote PC's read, which crosses the machine proxy, waits twice as long as a local one", () => { + expect(chatReadDeadlineMs("local")).toBe(15_000); + expect(chatReadDeadlineMs("studio-mac")).toBe(30_000); +}); diff --git a/src/lib/chatReadDeadline.ts b/src/lib/chatReadDeadline.ts index ec6ddbf522..c78d422f5a 100644 --- a/src/lib/chatReadDeadline.ts +++ b/src/lib/chatReadDeadline.ts @@ -1,5 +1,11 @@ +import { LOCAL_MACHINE } from "../../shared/machines.ts"; import { t } from "./i18n.ts"; +/** A remote PC's read crosses the machine proxy as well, so it gets twice the local deadline. */ +export function chatReadDeadlineMs(machineId: string): number { + return machineId === LOCAL_MACHINE ? 15_000 : 30_000; +} + /** Covers headers and body; even an implementation ignoring abort cannot hold the reader. */ export async function withChatReadDeadline(read: (signal: AbortSignal) => Promise, timeoutMs = 15_000): Promise { const controller = new AbortController(); From 245ae098672d3da435caa965742c74e36549df08 Mon Sep 17 00:00:00 2001 From: Hako Date: Sun, 11 Oct 2026 17:16:14 +0900 Subject: [PATCH 6/7] docs: drop the terminal fallback notes and screenshot added by this PR Co-Authored-By: Claude Opus 5.5 --- docs/development.md | 9 --------- .../chat-fallback/terminal-fallback-closed.png | Bin 8423 -> 0 bytes src/AGENTS.md | 2 -- 3 files changed, 11 deletions(-) delete mode 100644 docs/screenshots/chat-fallback/terminal-fallback-closed.png diff --git a/docs/development.md b/docs/development.md index 9f5bde41bb..45c92691b8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -469,12 +469,3 @@ input regression matrix. | [`docs/`](.) | Remote PCs, updates, flow control, chat audit, brand assets and README media (`docs/media/`) | | [`site/`](../site/) | The website, built by `scripts/build-site.ts` and deployed by GitHub Pages | | [`DESIGN.md`](../DESIGN.md) | Design tokens and UI conventions | - -### Optional terminal fallback - -Agent chats with scrollback fallback finish their conversation read before terminal output is -requested. The disclosure reads only while open and visible, independently of conversation -refresh. Non-agent scrollback stays inline. Conversation and terminal HTTP headers/body share -a 15-second deadline; expired answers cannot update the conversation ETag cache. - -Synthetic browser evidence: [preview](screenshots/chat-fallback/terminal-fallback-closed.png). diff --git a/docs/screenshots/chat-fallback/terminal-fallback-closed.png b/docs/screenshots/chat-fallback/terminal-fallback-closed.png deleted file mode 100644 index 4de175f9688c51e9e2c3a607f02be615dac0c4b3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8423 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 diff --git a/src/AGENTS.md b/src/AGENTS.md index 41d5f711f7..ea89bc463f 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -71,5 +71,3 @@ React 18 + xterm.js browser client. The render-state machine, xterm lifecycle an | Styling | styles.css + colocated .css | styles.css = `:root` tokens (+ `[data-theme="light"]`, `[data-density="compact"]`) + shell + shared primitives (`.icon-button .btn .pill .badge .segmented .kbd .modal .field .input .select .menu`); component looks live in their own .css; tokens only — no color literals | | Icons | lucide-react | the one icon set; AgentMark.tsx keeps the provider brand marks | | Workspace drawer / a11y | App.tsx + components/SidebarResizer.tsx + lib/sidebarWidth.ts | from 769px the sidebar's right edge resizes it (drag, arrows, Home/End, double-click resets); the width is per device (`herdr-web-ui:sidebar-width`) and reaches CSS as `--sidebar-user-w` on `.app`, read through `--sidebar-shown-w` by `.sidebar` and `.header-side` only, so dialogs sized from `--sidebar-w` do not move; toggle is `aria-expanded` + `aria-controls="workspace-drawer"`; scrim closes on tap; conn pill and banners are live regions; the conn pill is drawn only while not live and stays in the document (`.conn-live`, 1px, never `display: none`: the browser scripts wait on it) | - -- Chat read deadlines cover headers and body; apply conversation cache changes only after the bounded read succeeds. Agent terminal fallback reads run independently after the disclosure opens. From eb9489c5e6bcdcf95128b928bce9d3392dd64979 Mon Sep 17 00:00:00 2001 From: devswha Date: Sun, 11 Oct 2026 21:43:09 +0900 Subject: [PATCH 7/7] fix: address review threads --- scripts/chat-fallback-browser-qa.ts | 6 ++---- server/static.ts | 11 ++++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/scripts/chat-fallback-browser-qa.ts b/scripts/chat-fallback-browser-qa.ts index 3ab8432c88..d42f1de380 100644 --- a/scripts/chat-fallback-browser-qa.ts +++ b/scripts/chat-fallback-browser-qa.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { chromium } from "playwright-core"; import { build as viteBuild } from "vite"; -import { serveStatic } from "../server/static.ts"; +import { CONTENT_SECURITY_POLICY } from "../server/static.ts"; const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-chat-fallback-"))); let browser: Awaited> | undefined; let server: ReturnType | undefined; @@ -15,8 +15,6 @@ try { symlinkSync(join(repo, "scripts/chat-fallback-fixture.tsx"), join(root, "fixture.tsx")); writeFileSync(join(root, "index.html"), '
'); await viteBuild({ configFile: join(repo, "vite.config.ts"), root, publicDir: false, logLevel: "warn", build: { outDir: output, emptyOutDir: true } }); - const policy = (await serveStatic("/")).headers.get("content-security-policy"); - assert.ok(policy, "build the app before its enforcing CSP regression"); const terminalReads: string[] = []; let structured = false, resolveAgent!: () => void, agentReadArrived!: () => void; // resolved by the server's handler: a browser-side waitForRequest can win the race against it @@ -33,7 +31,7 @@ try { return response(); } if (url.pathname === "/favicon.ico") return new Response(null, { status: 204 }); - return new Response(Bun.file(join(output, url.pathname === "/" ? "index.html" : url.pathname.slice(1))), { headers: { "content-security-policy": policy } }); + return new Response(Bun.file(join(output, url.pathname === "/" ? "index.html" : url.pathname.slice(1))), { headers: { "content-security-policy": CONTENT_SECURITY_POLICY } }); } }); browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); const page = await browser.newPage(); page.setDefaultTimeout(10_000); diff --git a/server/static.ts b/server/static.ts index 7e09a84707..2d45197387 100644 --- a/server/static.ts +++ b/server/static.ts @@ -108,6 +108,12 @@ const CSP_DIRECTIVES = [ "frame-ancestors 'none'", ]; +/** + * The policy both headers carry. A browser script that serves a build of its own sends it as it is: + * serveStatic reads only this checkout's dist/, and sends no policy before that is built. + */ +export const CONTENT_SECURITY_POLICY = CSP_DIRECTIVES.join("; "); + /** * HERDR_WEB_CSP=report-only measures the policy above without enforcing it: the browser * logs what the policy would have blocked and the app keeps working. `report-only` is the @@ -115,10 +121,9 @@ const CSP_DIRECTIVES = [ */ function cspHeader(): Record { const mode = process.env["HERDR_WEB_CSP"]; - const policy = CSP_DIRECTIVES.join("; "); return mode === "report-only" - ? { "content-security-policy-report-only": policy } - : { "content-security-policy": policy }; + ? { "content-security-policy-report-only": CONTENT_SECURITY_POLICY } + : { "content-security-policy": CONTENT_SECURITY_POLICY }; } /**