Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,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 (30 seconds on a remote PC).
([#791](https://github.com/devswha/herdr-web-ui/pull/791) by @kilhyeonjun)
- Keep text file previews within their byte budget when a peer ignores Range, reject HTTP error bodies, and cancel stalled or closed previews.
([#785](https://github.com/devswha/herdr-web-ui/pull/785) by @kilhyeonjun)
- A Claude `/model` list whose hint a narrow pane wraps inside a word or over more than six lines
Expand Down
2 changes: 1 addition & 1 deletion scripts/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
83 changes: 83 additions & 0 deletions scripts/chat-fallback-browser-qa.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/** 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 { CONTENT_SECURITY_POLICY } from "../server/static.ts";
const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-chat-fallback-")));
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
let server: ReturnType<typeof Bun.serve> | 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"), '<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><div id="root"></div><script type="module" src="/fixture.tsx"></script></body></html>');
await viteBuild({ configFile: join(repo, "vite.config.ts"), root, publicDir: false, logLevel: "warn", build: { outDir: output, emptyOutDir: true } });
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
const agentRead = new Promise<void>((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 });
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<Response>((resolve) => { resolveAgent = () => resolve(response()); agentReadArrived(); });
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": 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);
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");
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") });
}
await summary.click();
let deadline: ReturnType<typeof setTimeout> | undefined;
await Promise.race([agentRead, new Promise<never>((_, 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());
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");
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, non-agent inline output, fallback error announcement and accurate empty state)");

} finally {
await browser?.close(); server?.stop(true); rmSync(root, { recursive: true, force: true });
}
14 changes: 14 additions & 0 deletions scripts/chat-fallback-fixture.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>("codex");
const [pane, setPane] = useState("agent");
const [refresh, setRefresh] = useState(0);
window.chatFallbackQA = { pane: setPane, agent: setAgent, refresh: () => setRefresh((key) => key + 1) };
return <main style={{ height: "100vh" }}><ChatView key={pane} paneId={pane} refreshKey={refresh} connected ended={false} agent={pane === "shell" ? null : agent} /></main>;
}
createRoot(document.getElementById("root")!).render(<React.StrictMode><SettingsProvider><Fixture /></SettingsProvider></React.StrictMode>);
1 change: 1 addition & 0 deletions scripts/ci-browser.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,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/grok-chat-browser-qa.ts
run_script scripts/math-browser-qa.ts
run_script scripts/file-viewer-regression.ts
Expand Down
3 changes: 2 additions & 1 deletion scripts/ci-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(32);
expect(rows(text)).toHaveLength(33);
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/file-preview-text-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");
Expand Down
11 changes: 8 additions & 3 deletions server/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,17 +108,22 @@ 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
* measurement mode; anything else enforces.
*/
function cspHeader(): Record<string, string> {
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 };
}

/**
Expand Down
44 changes: 40 additions & 4 deletions src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,41 @@ function FallbackTurn({ paneId, message }: { paneId: string; message: Transcript
return <Turn paneId={paneId} turn={turn} live={false} waiting={false} showThinking={false} />;
}

/** 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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const visible = usePageVisible();
useEffect(() => {
if (!open || !visible) return;
let cancelled = false;
let timer: number | undefined;
const read = async (): Promise<void> => {
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 <details className="chat-terminal-fallback" open={open} onToggle={(event) => setOpen(event.currentTarget.open)}>
<summary>{t("Conversation unavailable — show terminal output")}</summary>
{text !== null && <pre>{text}</pre>}
{text === null && error === null && <p className="chat-inline-state" role="status">{t("Loading…")}</p>}
{error !== null && <p className="chat-inline-state chat-inline-error" role="alert">{error}</p>}
</details>;
}

// 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();
Expand Down Expand Up @@ -711,6 +746,7 @@ export const ChatView = memo(function ChatView({ paneId, refreshKey, invalidatio
setAbandoned(conversation.abandoned ?? null);
let next: ChatState;
if (conversation.source !== "scrollback") next = { source: "conversation", conversationSource: conversation.source, turns: conversation.turns, messages: [], truncated: false };
else if (agent !== null) next = { source: "scrollback", turns: [], messages: [], truncated: false };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
else {
const result = await fetchPaneTranscript(paneId, TRANSCRIPT_LINES);
if (cancelled || generation !== olderGeneration.current) return;
Expand Down Expand Up @@ -743,13 +779,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(() => {
Expand Down Expand Up @@ -937,7 +973,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";
Expand Down Expand Up @@ -981,7 +1017,7 @@ export const ChatView = memo(function ChatView({ paneId, refreshKey, invalidatio
</RenderBoundary>;
})
: agent !== null
? <details className="chat-terminal-fallback"><summary>{t("Conversation unavailable — show terminal output")}</summary><pre>{state.messages.map((message) => message.text).join("\n\n")}</pre></details>
? <TerminalFallback key={paneId} paneId={paneId} />
: state.messages.map((message, index) => <FallbackTurn key={index} paneId={paneId} message={message} />)}
{!ended && !connected && <p className="chat-inline-state" role="status">{t("Reconnecting…")}</p>}
{error !== null && <p className="chat-inline-state chat-inline-error" role="alert">{errorStatus === 401 ? t("locked — the token gate is asking again") : error}</p>}
Expand Down
Loading
Loading