diff --git a/coding-agent/webui/app/src/App.test.tsx b/coding-agent/webui/app/src/App.test.tsx index 95fd5b761..15b0848d4 100644 --- a/coding-agent/webui/app/src/App.test.tsx +++ b/coding-agent/webui/app/src/App.test.tsx @@ -642,7 +642,10 @@ describe("App session switching", () => { arguments: { cmd: "pwd" }, }, }, - created_at: "2026-06-12T00:00:00Z", + // Live-streamed prompt: created_at must be "now" so the approval + // countdown deadline (created_at + timeout_seconds) is not already + // expired when the card mounts. + created_at: new Date().toISOString(), }; return new Response( new ReadableStream({ @@ -897,6 +900,8 @@ describe("App session switching", () => { fireEvent.click(await screen.findByRole("button", { name: /model-memory/i })); await screen.findByText("idle"); + // The memory panel lives in the right rail now; open it to observe state. + fireEvent.click(screen.getByRole("button", { name: "Toggle memory panel" })); // loadSession's refreshMemory (seq 1) is now in flight. await waitFor(() => expect(refreshRuns).toHaveLength(1)); @@ -947,6 +952,7 @@ describe("App session switching", () => { render(); fireEvent.click(await screen.findByRole("button", { name: /model-broken/i })); + fireEvent.click(screen.getByRole("button", { name: "Toggle memory panel" })); expect(await screen.findByText("restore failed")).toBeTruthy(); expect(await screen.findByText(/memory load failed/)).toBeTruthy(); @@ -1211,3 +1217,1045 @@ describe("provider model list", () => { expect(datalistValues()).toEqual(["kimi-for-coding", "k3", "deepseek-chat"]); }); }); + +describe("right rail", () => { + beforeEach(() => { + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + clear: () => storage.clear(), + removeItem: (key: string) => storage.delete(key), + }); + Element.prototype.scrollIntoView = vi.fn(); + localStorage.setItem( + "coding-agent-webui-config", + JSON.stringify({ baseUrl: "http://127.0.0.1:18080", apiKey: "" }), + ); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + vi.restoreAllMocks(); + }); + + const stubRailFetch = (active: ReturnType) => { + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [active] })); + } + if (url.endsWith(`/sessions/${active.session_id}`)) { + return Promise.resolve(jsonResponse(active)); + } + if (url.endsWith(`/sessions/${active.session_id}/runs`)) { + return Promise.resolve(jsonResponse({ session_id: active.session_id, runs: [] })); + } + if (url.includes(`/sessions/${active.session_id}/memory/reviews`)) { + return Promise.resolve(jsonResponse([])); + } + if (url.endsWith(`/sessions/${active.session_id}/workspace/diff`)) { + return Promise.resolve( + jsonResponse({ session_id: active.session_id, files: [], additions: 0, deletions: 0 }), + ); + } + if (url.endsWith(`/sessions/${active.session_id}/workspace/patch`)) { + return Promise.resolve( + jsonResponse({ session_id: active.session_id, format: "unified_diff", patch: "" }), + ); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + }; + + it("opens, switches, and collapses rail panels", async () => { + const active = session("session-rail-0001", "model-rail"); + stubRailFetch(active); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /model-rail/i })); + await screen.findByText("idle"); + + const memoryToggle = screen.getByRole("button", { name: "Toggle memory panel" }); + const diffToggle = screen.getByRole("button", { name: "Toggle diff panel" }); + + // Open memory panel. + fireEvent.click(memoryToggle); + expect(await screen.findByText("Recall hits")).toBeTruthy(); + expect(memoryToggle.getAttribute("aria-pressed")).toBe("true"); + + // Switch to diff: memory closes, diff opens (fetched on demand). + fireEvent.click(diffToggle); + expect(await screen.findByText(/Workspace diff/)).toBeTruthy(); + expect(screen.queryByText("Recall hits")).toBeNull(); + expect(diffToggle.getAttribute("aria-pressed")).toBe("true"); + expect(memoryToggle.getAttribute("aria-pressed")).toBe("false"); + + // Clicking the active icon collapses the panel. + fireEvent.click(diffToggle); + expect(screen.queryByText(/Workspace diff/)).toBeNull(); + expect(diffToggle.getAttribute("aria-pressed")).toBe("false"); + }); + + it("shows a placeholder instead of panels when there is no active session", async () => { + const active = session("session-rail-0002", "model-rail"); + stubRailFetch(active); + + render(); + await screen.findByRole("button", { name: /model-rail/i }); + + fireEvent.click(screen.getByRole("button", { name: "Toggle memory panel" })); + expect(await screen.findByText("No active session")).toBeTruthy(); + }); + + it("collapses and restores the sidebar via the header toggle", async () => { + const active = session("session-rail-0003", "model-rail"); + stubRailFetch(active); + + render(); + await screen.findByRole("button", { name: /model-rail/i }); + + const sidebar = screen.getByLabelText("Sessions"); + expect(sidebar.className).not.toContain("hidden"); + + fireEvent.click(screen.getByRole("button", { name: "toggle sidebar" })); + expect(sidebar.className).toContain("hidden"); + + fireEvent.click(screen.getByRole("button", { name: "toggle sidebar" })); + expect(sidebar.className).not.toContain("hidden"); + }); + + it("starts with the sidebar closed on small screens", async () => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + matches: true, + media: "(max-width: 767px)", + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + const active = session("session-rail-0004", "model-rail"); + stubRailFetch(active); + + render(); + await screen.findByRole("button", { name: /model-rail/i }); + + expect(screen.getByLabelText("Sessions").className).toContain("hidden"); + }); +}); + +describe("session result refresh", () => { + beforeEach(() => { + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + clear: () => storage.clear(), + removeItem: (key: string) => storage.delete(key), + }); + Element.prototype.scrollIntoView = vi.fn(); + localStorage.setItem( + "coding-agent-webui-config", + JSON.stringify({ baseUrl: "http://127.0.0.1:18080", apiKey: "" }), + ); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("retries an empty terminal result after the prompt stream closes", async () => { + const active = session("session-result-retry-0001", "model-result"); + let resultCalls = 0; + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [active] })); + } + if (url.endsWith(`/sessions/${active.session_id}`)) { + return Promise.resolve(jsonResponse(active)); + } + if (url.endsWith(`/sessions/${active.session_id}/runs`)) { + return Promise.resolve(jsonResponse({ session_id: active.session_id, runs: [] })); + } + if (url.includes(`/sessions/${active.session_id}/memory/reviews`)) { + return Promise.resolve(jsonResponse([])); + } + if (url.endsWith(`/sessions/${active.session_id}/result`)) { + resultCalls += 1; + const visible = resultCalls >= 3; + return Promise.resolve( + jsonResponse({ + session_id: active.session_id, + status: "failed", + turn_status: "failed", + turn_id: "turn-result", + workspace_id: null, + origin: null, + provider_name: "test", + model_name: active.model_name, + final_answer: null, + verification_summary: null, + failure_details: visible ? "delayed failure details" : null, + }), + ); + } + if (url.includes(`/sessions/${active.session_id}/prompt?event_format=display`)) { + return Promise.resolve(new Response("", { status: 200 })); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /model-result/i })); + await screen.findByText("idle"); + fireEvent.change(screen.getByPlaceholderText(/ask the agent/i), { + target: { value: "run" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + + expect(await screen.findByText("delayed failure details")).toBeTruthy(); + expect(resultCalls).toBe(3); + }); +}); + + +describe("thinking toggle", () => { + beforeEach(() => { + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + clear: () => storage.clear(), + removeItem: (key: string) => storage.delete(key), + }); + Element.prototype.scrollIntoView = vi.fn(); + localStorage.setItem( + "coding-agent-webui-config", + JSON.stringify({ baseUrl: "http://127.0.0.1:18080", apiKey: "" }), + ); + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [] })); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("defaults to showing thinking and persists the toggle", async () => { + render(); + + const btn = screen.getByRole("button", { name: "toggle thinking" }); + expect(btn.getAttribute("aria-pressed")).toBe("true"); + + fireEvent.click(btn); + expect(btn.getAttribute("aria-pressed")).toBe("false"); + expect(localStorage.getItem("coding-agent-webui-show-thinking")).toBe("0"); + + fireEvent.click(btn); + expect(btn.getAttribute("aria-pressed")).toBe("true"); + expect(localStorage.getItem("coding-agent-webui-show-thinking")).toBe("1"); + }); +}); + +describe("approval scope", () => { + beforeEach(() => { + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + clear: () => storage.clear(), + removeItem: (key: string) => storage.delete(key), + }); + Element.prototype.scrollIntoView = vi.fn(); + localStorage.setItem( + "coding-agent-webui-config", + JSON.stringify({ baseUrl: "http://127.0.0.1:18080", apiKey: "", approval: "auto" }), + ); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("sends scope=session when choosing always allow", async () => { + const active = session("session-approval-0002", "model-approval"); + let approveBody: Record | null = null; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return jsonResponse({ sessions: [active] }); + } + if (url.endsWith(`/sessions/${active.session_id}`)) { + return jsonResponse(active); + } + if (url.endsWith(`/sessions/${active.session_id}/runs`)) { + return jsonResponse({ session_id: active.session_id, runs: [] }); + } + if (url.includes(`/sessions/${active.session_id}/prompt?event_format=display`)) { + const payload = { + source_event_id: "event-approval", + run_id: "run-approval", + sequence: 1, + display_kind: "approval_prompt", + payload: { + agent_id: "", + request_id: "approval-9", + timeout_seconds: 60, + tool_call: { + call_id: "call-1", + tool_name: "bash", + arguments: { cmd: "pwd" }, + }, + }, + // Live-streamed prompt: created_at must be "now" so the approval + // countdown deadline (created_at + timeout_seconds) is not already + // expired when the card mounts. + created_at: new Date().toISOString(), + }; + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + `event: approval_prompt\ndata: ${JSON.stringify(payload)}\n\n`, + ), + ); + controller.close(); + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ); + } + if (url.endsWith(`/sessions/${active.session_id}/approve`)) { + approveBody = JSON.parse(String(init?.body)) as Record; + return jsonResponse({ + status: "approved", + request_id: "approval-9", + decision: "approved", + }); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /model-approval/i })); + await screen.findByText("idle"); + fireEvent.change(screen.getByPlaceholderText(/ask the agent/i), { + target: { value: "run a tool" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + + expect(await screen.findByText("Approval Required")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Always allow (this session)" })); + + await waitFor(() => + expect(approveBody).toEqual({ + request_id: "approval-9", + approved: true, + feedback: null, + scope: "session", + }), + ); + expect(screen.getByText("→ approved")).toBeTruthy(); + }); +}); + +describe("session settings panel", () => { + beforeEach(() => { + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + clear: () => storage.clear(), + removeItem: (key: string) => storage.delete(key), + }); + Element.prototype.scrollIntoView = vi.fn(); + localStorage.setItem( + "coding-agent-webui-config", + JSON.stringify({ baseUrl: "http://127.0.0.1:18080", apiKey: "", approval: "auto" }), + ); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("patches runtime config when session settings change", async () => { + const active = session("session-settings-0001", "model-settings"); + const patches: Array<{ method: string | undefined; body: Record }> = []; + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [active] })); + } + if (url.endsWith(`/sessions/${active.session_id}/runtime-config`)) { + const body = JSON.parse(String(init?.body)) as Record; + patches.push({ + method: init?.method, + body, + }); + return Promise.resolve( + jsonResponse({ + session_id: active.session_id, + provider_name: (body.provider as string | undefined) ?? active.provider_name, + model_name: (body.model as string | undefined) ?? active.model_name, + base_url: null, + }), + ); + } + if (url.endsWith(`/sessions/${active.session_id}`)) { + return Promise.resolve(jsonResponse(active)); + } + if (url.endsWith(`/sessions/${active.session_id}/runs`)) { + return Promise.resolve(jsonResponse({ session_id: active.session_id, runs: [] })); + } + if (url.includes(`/sessions/${active.session_id}/memory/reviews`)) { + return Promise.resolve(jsonResponse([])); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /model-settings/i })); + await screen.findByText("idle"); + fireEvent.click(screen.getByRole("button", { name: "Toggle settings panel" })); + expect(await screen.findByText("Session settings")).toBeTruthy(); + + fireEvent.change(screen.getByTitle("session approval policy"), { + target: { value: "yolo" }, + }); + await waitFor(() => + expect(patches).toContainEqual({ method: "PATCH", body: { approval: "yolo" } }), + ); + // Approval changes also update the persisted new-session default. + expect( + (JSON.parse(localStorage.getItem("coding-agent-webui-config") ?? "{}") as { approval?: string }) + .approval, + ).toBe("yolo"); + + fireEvent.change(screen.getByTitle("thinking effort"), { + target: { value: "high" }, + }); + await waitFor(() => + expect(patches).toContainEqual({ + method: "PATCH", + body: { thinking: { enabled: true, effort: "high" } }, + }), + ); + + fireEvent.change(screen.getByTitle("session model"), { + target: { value: "model-override" }, + }); + fireEvent.blur(screen.getByTitle("session model")); + await waitFor(() => + expect(patches).toContainEqual({ method: "PATCH", body: { model: "model-override" } }), + ); + + expect(await screen.findByText("Saved")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Toggle settings panel" })); + fireEvent.click(screen.getByRole("button", { name: "Toggle settings panel" })); + expect((await screen.findByTitle("session model") as HTMLInputElement).value).toBe( + "model-override", + ); + }); + + it("keeps queued settings updates bound to their originating session", async () => { + const first = session("session-settings-0002", "model-settings-a"); + const second = session("session-settings-0003", "model-settings-b"); + const firstPatch = deferred(); + const patchUrls: string[] = []; + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [first, second] })); + } + if (url.endsWith(`/sessions/${first.session_id}/runtime-config`)) { + patchUrls.push(url); + if (patchUrls.length === 1) return firstPatch.promise; + return Promise.resolve( + jsonResponse({ + session_id: first.session_id, + provider_name: first.provider_name, + model_name: first.model_name, + base_url: null, + }), + ); + } + if (url.endsWith(`/sessions/${second.session_id}/runtime-config`)) { + patchUrls.push(url); + return Promise.resolve( + jsonResponse({ + session_id: second.session_id, + provider_name: second.provider_name, + model_name: second.model_name, + base_url: null, + }), + ); + } + if (url.endsWith(`/sessions/${first.session_id}`)) { + return Promise.resolve(jsonResponse(first)); + } + if (url.endsWith(`/sessions/${second.session_id}`)) { + return Promise.resolve(jsonResponse(second)); + } + if (url.endsWith(`/sessions/${first.session_id}/runs`)) { + return Promise.resolve(jsonResponse({ session_id: first.session_id, runs: [] })); + } + if (url.endsWith(`/sessions/${second.session_id}/runs`)) { + return Promise.resolve(jsonResponse({ session_id: second.session_id, runs: [] })); + } + if (url.includes(`/sessions/${first.session_id}/memory/reviews`)) { + return Promise.resolve(jsonResponse([])); + } + if (url.includes(`/sessions/${second.session_id}/memory/reviews`)) { + return Promise.resolve(jsonResponse([])); + } + throw new Error(`unexpected fetch ${url} ${init?.method ?? "GET"}`); + }), + ); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /model-settings-a/i })); + await screen.findByText("idle"); + fireEvent.click(screen.getByRole("button", { name: "Toggle settings panel" })); + const approval = await screen.findByTitle("session approval policy"); + + fireEvent.change(approval, { target: { value: "yolo" } }); + await waitFor(() => expect(patchUrls).toHaveLength(1)); + fireEvent.change(approval, { target: { value: "interactive" } }); + + fireEvent.click(screen.getByRole("button", { name: /model-settings-b/i })); + await screen.findByText("idle"); + + firstPatch.resolve( + jsonResponse({ + session_id: first.session_id, + provider_name: first.provider_name, + model_name: first.model_name, + base_url: null, + }), + ); + + await waitFor(() => expect(patchUrls).toHaveLength(2)); + expect(patchUrls).toEqual([ + expect.stringContaining(`/sessions/${first.session_id}/runtime-config`), + expect.stringContaining(`/sessions/${first.session_id}/runtime-config`), + ]); + }); + + it("shows a placeholder when there is no active session", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [] })); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Toggle settings panel" })); + expect(await screen.findByText("No active session")).toBeTruthy(); + }); +}); + +describe("P2 race guards", () => { + beforeEach(() => { + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + clear: () => storage.clear(), + removeItem: (key: string) => storage.delete(key), + }); + Element.prototype.scrollIntoView = vi.fn(); + localStorage.setItem( + "coding-agent-webui-config", + JSON.stringify({ + baseUrl: "http://127.0.0.1:18080", + apiKey: "", + repoPath: "", + approval: "auto", + }), + ); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("applies only the active session's diff when fetches resolve out of order", async () => { + const first = session("session-diff-0001", "model-diff-a"); + const second = session("session-diff-0002", "model-diff-b"); + const diffA = deferred(); + const diffB = deferred(); + const patchA = deferred(); + const patchB = deferred(); + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [first, second] })); + } + if (url.endsWith(`/sessions/${first.session_id}`)) { + return Promise.resolve(jsonResponse(first)); + } + if (url.endsWith(`/sessions/${second.session_id}`)) { + return Promise.resolve(jsonResponse(second)); + } + if (url.endsWith(`/sessions/${first.session_id}/runs`)) { + return Promise.resolve(jsonResponse({ session_id: first.session_id, runs: [] })); + } + if (url.endsWith(`/sessions/${second.session_id}/runs`)) { + return Promise.resolve(jsonResponse({ session_id: second.session_id, runs: [] })); + } + if (url.includes(`/sessions/${first.session_id}/memory/reviews`)) { + return Promise.resolve(jsonResponse([])); + } + if (url.includes(`/sessions/${second.session_id}/memory/reviews`)) { + return Promise.resolve(jsonResponse([])); + } + if (url.endsWith(`/sessions/${first.session_id}/workspace/diff`)) { + return diffA.promise; + } + if (url.endsWith(`/sessions/${second.session_id}/workspace/diff`)) { + return diffB.promise; + } + if (url.endsWith(`/sessions/${first.session_id}/workspace/patch`)) { + return patchA.promise; + } + if (url.endsWith(`/sessions/${second.session_id}/workspace/patch`)) { + return patchB.promise; + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /model-diff-a/i })); + await screen.findByText("idle"); + // Open the diff panel in session A; its fetch stays in flight. + fireEvent.click(screen.getByRole("button", { name: "Toggle diff panel" })); + + // Switch to session B before A's fetch resolves, then open its diff panel. + fireEvent.click(screen.getByRole("button", { name: /model-diff-b/i })); + await screen.findByText("idle"); + fireEvent.click(screen.getByRole("button", { name: "Toggle diff panel" })); + + // B's diff resolves first and applies. + diffB.resolve( + jsonResponse({ + session_id: second.session_id, + files: [{ path: "b-file.ts", status: "modified", additions: 1, deletions: 0 }], + additions: 1, + deletions: 0, + }), + ); + patchB.resolve( + jsonResponse({ session_id: second.session_id, format: "unified_diff", patch: "" }), + ); + expect(await screen.findByText("b-file.ts")).toBeTruthy(); + + // A's stale fetch resolves last and must not clobber B's panel. + diffA.resolve( + jsonResponse({ + session_id: first.session_id, + files: [{ path: "a-file.ts", status: "modified", additions: 9, deletions: 9 }], + additions: 9, + deletions: 9, + }), + ); + patchA.resolve( + jsonResponse({ session_id: first.session_id, format: "unified_diff", patch: "" }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(screen.getByText("b-file.ts")).toBeTruthy(); + expect(screen.queryByText("a-file.ts")).toBeNull(); + }); + + it("does not reopen the checkpoints panel when the session changes mid-restore", async () => { + const first = session("session-restore-0001", "model-restore-a", { checkpoint_count: 1 }); + const second = session("session-restore-0002", "model-restore-b"); + const checkpoint = { + checkpoint_id: "cp-1", + tape_id: "tape-1", + session_id: first.session_id, + entry_count: 3, + window_start: 0, + created_at: "2026-06-12T00:00:00Z", + label: "before-refactor", + }; + const reloadSummary = deferred(); + let firstSummaryCalls = 0; + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [first, second] })); + } + if (url.endsWith(`/sessions/${first.session_id}/checkpoints`)) { + return Promise.resolve(jsonResponse({ checkpoints: [checkpoint] })); + } + if (url.endsWith(`/sessions/${first.session_id}/checkpoints/cp-1/restore`)) { + return Promise.resolve(jsonResponse({})); + } + if (url.endsWith(`/sessions/${first.session_id}`)) { + firstSummaryCalls += 1; + // The reload after restore stays in flight until we switch sessions. + if (firstSummaryCalls > 1) return reloadSummary.promise; + return Promise.resolve(jsonResponse(first)); + } + if (url.endsWith(`/sessions/${second.session_id}`)) { + return Promise.resolve(jsonResponse(second)); + } + if (url.endsWith(`/sessions/${first.session_id}/runs`)) { + return Promise.resolve(jsonResponse({ session_id: first.session_id, runs: [] })); + } + if (url.endsWith(`/sessions/${second.session_id}/runs`)) { + return Promise.resolve(jsonResponse({ session_id: second.session_id, runs: [] })); + } + if (url.includes(`/sessions/${first.session_id}/memory/reviews`)) { + return Promise.resolve(jsonResponse([])); + } + if (url.includes(`/sessions/${second.session_id}/memory/reviews`)) { + return Promise.resolve(jsonResponse([])); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /model-restore-a/i })); + await screen.findByText("idle"); + + // Open the checkpoints panel and restore the checkpoint. + fireEvent.click(screen.getByRole("button", { name: "Toggle checkpoints panel" })); + expect(await screen.findByText("before-refactor")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Restore" })); + fireEvent.click(screen.getByRole("button", { name: "Confirm restore" })); + + // The restore triggers a reload of session A that is still in flight. + await waitFor(() => expect(firstSummaryCalls).toBe(2)); + + // Switch to session B before A's reload resolves. + fireEvent.click(screen.getByRole("button", { name: /model-restore-b/i })); + await screen.findByText("idle"); + + // A's stale reload resolves now; it must not reopen the checkpoints panel. + reloadSummary.resolve(jsonResponse(first)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(screen.queryByText(/Checkpoints ·/)).toBeNull(); + expect( + screen.getByRole("button", { name: "Toggle checkpoints panel" }).getAttribute("aria-pressed"), + ).toBe("false"); + }); + + it("does not reload the new active session when an old checkpoint restore finishes", async () => { + const first = session("session-restore-0003", "model-restore-c", { checkpoint_count: 1 }); + const second = session("session-restore-0004", "model-restore-d"); + const restoreResponse = deferred(); + let secondSummaryCalls = 0; + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [first, second] })); + } + if (url.endsWith(`/sessions/${first.session_id}/checkpoints`)) { + return Promise.resolve( + jsonResponse({ + checkpoints: [ + { + checkpoint_id: "cp-2", + tape_id: "tape-2", + session_id: first.session_id, + entry_count: 4, + window_start: 0, + created_at: "2026-06-12T00:00:00Z", + label: "before-switch", + }, + ], + }), + ); + } + if (url.endsWith(`/sessions/${first.session_id}/checkpoints/cp-2/restore`)) { + return restoreResponse.promise; + } + if (url.endsWith(`/sessions/${first.session_id}`)) { + return Promise.resolve(jsonResponse(first)); + } + if (url.endsWith(`/sessions/${second.session_id}`)) { + secondSummaryCalls += 1; + return Promise.resolve(jsonResponse(second)); + } + if (url.endsWith(`/sessions/${first.session_id}/runs`)) { + return Promise.resolve(jsonResponse({ session_id: first.session_id, runs: [] })); + } + if (url.endsWith(`/sessions/${second.session_id}/runs`)) { + return Promise.resolve(jsonResponse({ session_id: second.session_id, runs: [] })); + } + if (url.includes(`/sessions/${first.session_id}/memory/reviews`)) { + return Promise.resolve(jsonResponse([])); + } + if (url.includes(`/sessions/${second.session_id}/memory/reviews`)) { + return Promise.resolve(jsonResponse([])); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /model-restore-c/i })); + await screen.findByText("idle"); + fireEvent.click(screen.getByRole("button", { name: "Toggle checkpoints panel" })); + expect(await screen.findByText("before-switch")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Restore" })); + fireEvent.click(screen.getByRole("button", { name: "Confirm restore" })); + + fireEvent.click(screen.getByRole("button", { name: /model-restore-d/i })); + await screen.findByText("idle"); + expect(secondSummaryCalls).toBe(1); + + restoreResponse.resolve(jsonResponse({ status: "restored" })); + + await waitFor(() => + expect( + screen.getByRole("button", { name: "Toggle checkpoints panel" }).getAttribute( + "aria-pressed", + ), + ).toBe("false"), + ); + expect(secondSummaryCalls).toBe(1); + }); + + it("rolls back the optimistic resolve when posting an approval fails", async () => { + const active = session("session-approval-fail", "model-approval-fail"); + + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return jsonResponse({ sessions: [active] }); + } + if (url.endsWith(`/sessions/${active.session_id}`)) { + return jsonResponse(active); + } + if (url.endsWith(`/sessions/${active.session_id}/runs`)) { + return jsonResponse({ session_id: active.session_id, runs: [] }); + } + if (url.includes(`/sessions/${active.session_id}/memory/reviews`)) { + return jsonResponse([]); + } + if (url.includes(`/sessions/${active.session_id}/prompt?event_format=display`)) { + const payload = { + source_event_id: "event-approval", + run_id: "run-approval", + sequence: 1, + display_kind: "approval_prompt", + payload: { + agent_id: "", + request_id: "approval-1", + timeout_seconds: 60, + tool_call: { + call_id: "call-1", + tool_name: "bash", + arguments: { cmd: "pwd" }, + }, + }, + // Live-streamed prompt: created_at must be "now" so the approval + // countdown deadline is not already expired when the card mounts. + created_at: new Date().toISOString(), + }; + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + `event: approval_prompt\ndata: ${JSON.stringify(payload)}\n\n`, + ), + ); + controller.close(); + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ); + } + if (url.endsWith(`/sessions/${active.session_id}/approve`)) { + return new Response("boom", { status: 500 }); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /model-approval-fail/i })); + await screen.findByText("idle"); + fireEvent.change(screen.getByPlaceholderText(/ask the agent/i), { + target: { value: "run a tool" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + + expect(await screen.findByText("Approval Required")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + + // The failure is surfaced and the optimistic "approved" state is rolled + // back so the card becomes actionable again. + expect(await screen.findByText(/approve failed/)).toBeTruthy(); + expect(screen.queryByText("→ approved")).toBeNull(); + expect(screen.getByRole("button", { name: "Approve" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Deny" })).toBeTruthy(); + }); + + it("does not write an old session's approval failure into the new session", async () => { + const first = session("session-approval-fail-a", "model-approval-a"); + const second = session("session-approval-fail-b", "model-approval-b"); + const approvalResponse = deferred(); + + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return jsonResponse({ sessions: [first, second] }); + } + if (url.endsWith(`/sessions/${first.session_id}`)) { + return jsonResponse(first); + } + if (url.endsWith(`/sessions/${second.session_id}`)) { + return jsonResponse(second); + } + if (url.endsWith(`/sessions/${first.session_id}/runs`)) { + return jsonResponse({ session_id: first.session_id, runs: [] }); + } + if (url.endsWith(`/sessions/${second.session_id}/runs`)) { + return jsonResponse({ session_id: second.session_id, runs: [] }); + } + if (url.includes(`/sessions/${first.session_id}/memory/reviews`)) { + return jsonResponse([]); + } + if (url.includes(`/sessions/${second.session_id}/memory/reviews`)) { + return jsonResponse([]); + } + if (url.includes(`/sessions/${first.session_id}/prompt?event_format=display`)) { + const payload = { + source_event_id: "event-approval-stale", + run_id: "run-approval-stale", + sequence: 1, + display_kind: "approval_prompt", + payload: { + agent_id: "", + request_id: "approval-stale", + timeout_seconds: 60, + tool_call: { + call_id: "call-stale", + tool_name: "bash", + arguments: { cmd: "pwd" }, + }, + }, + created_at: new Date().toISOString(), + }; + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + `event: approval_prompt\ndata: ${JSON.stringify(payload)}\n\n`, + ), + ); + controller.close(); + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ); + } + if (url.endsWith(`/sessions/${first.session_id}/approve`)) { + return approvalResponse.promise; + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /model-approval-a/i })); + await screen.findByText("idle"); + fireEvent.change(screen.getByPlaceholderText(/ask the agent/i), { + target: { value: "run a tool" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + expect(await screen.findByText("Approval Required")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + + fireEvent.click(screen.getByRole("button", { name: /model-approval-b/i })); + await screen.findByText("idle"); + + approvalResponse.resolve(new Response("boom", { status: 500 })); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(screen.queryByText(/approve failed/)).toBeNull(); + }); +}); diff --git a/coding-agent/webui/app/src/App.tsx b/coding-agent/webui/app/src/App.tsx index 714160af7..0cf868a34 100644 --- a/coding-agent/webui/app/src/App.tsx +++ b/coding-agent/webui/app/src/App.tsx @@ -1,13 +1,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { AgentClient } from "./lib/api"; +import { AgentClient, type RuntimeConfigPatch } from "./lib/api"; import type { ApprovalPolicy, + ApprovalScope, ContextPackItem, DisplayEventEnvelope, DisplayStreamEvent, FinalResultPayload, MemoryReviewRecord, ProgressPayload, + SessionResult, SessionSummary, WorkspaceDiff, WorkspacePatch, @@ -26,9 +28,15 @@ import Composer from "./components/Composer"; import SessionList from "./components/SessionList"; import DiffPanel from "./components/DiffPanel"; import MemoryPanel, { extractRecallHits } from "./components/MemoryPanel"; +import RightRail, { type RailPanel } from "./components/RightRail"; +import SettingsPanel from "./components/SettingsPanel"; +import CheckpointsPanel from "./components/CheckpointsPanel"; +import ResultPanel, { hasResultContent } from "./components/ResultPanel"; const LS_KEY = "coding-agent-webui-config"; const THEME_LS_KEY = "coding-agent-webui-theme"; +const THINKING_LS_KEY = "coding-agent-webui-show-thinking"; +const RESULT_RETRY_DELAYS_MS = [0, 250, 500, 1_000, 2_000, 4_000] as const; type Theme = "dark" | "light"; type Config = { baseUrl: string; @@ -73,6 +81,21 @@ function loadTheme(): Theme { } } +function loadShowThinking(): boolean { + try { + return localStorage.getItem(THINKING_LS_KEY) !== "0"; + } catch { + return true; + } +} + +function initialSidebarOpen(): boolean { + return ( + typeof window.matchMedia !== "function" || + !window.matchMedia("(max-width: 767px)").matches + ); +} + export default function App() { const [config, setConfig] = useState(loadConfig); const [theme, setTheme] = useState(loadTheme); @@ -95,10 +118,16 @@ export default function App() { memories: MemoryReviewRecord[]; error: string | null; } | null>(null); + const [sidebarOpen, setSidebarOpen] = useState(initialSidebarOpen); + const [railPanel, setRailPanel] = useState(null); + const [showThinking, setShowThinking] = useState(loadShowThinking); + const [resultState, setResultState] = useState(null); const abortRef = useRef(null); const followAbortRef = useRef(null); const loadSeqRef = useRef(0); const memorySeqRef = useRef(0); + const resultSeqRef = useRef(0); + const diffSeqRef = useRef(0); const activeSessionRef = useRef(null); const sessionLoadingRef = useRef(false); @@ -120,6 +149,17 @@ export default function App() { setTheme((t) => (t === "dark" ? "light" : "dark")); }, []); + const toggleThinking = useCallback(() => { + setShowThinking((v) => { + try { + localStorage.setItem(THINKING_LS_KEY, v ? "0" : "1"); + } catch { + /* ignore */ + } + return !v; + }); + }, []); + const patchConfig = useCallback((p: Partial) => { setConfig((c) => { const next = { ...c, ...p }; @@ -130,20 +170,29 @@ export default function App() { } return next; }); - const activeSessionId = activeSessionRef.current; - if (p.approval !== undefined && activeSessionId) { - void client.updateRuntimeConfig(activeSessionId, { approval: p.approval }).catch((e) => { - setItems((prev) => [ - ...prev, - { - id: `e${Date.now()}`, - kind: "error", - text: `approval policy update failed: ${msg(e)}`, - }, - ]); - }); - } - }, [client]); + }, []); + + // Per-session runtime settings (model/provider/thinking/approval). Approval + // changes also update the persisted new-session default. + const updateSessionRuntime = useCallback( + async (id: string, patch: RuntimeConfigPatch) => { + const updated = await client.updateRuntimeConfig(id, patch); + setSessions((current) => + current.map((entry) => { + if (entry.session_id !== id) return entry; + return { + ...entry, + provider_name: + patch.provider !== undefined ? updated.provider_name : entry.provider_name, + model_name: patch.model !== undefined ? updated.model_name : entry.model_name, + base_url: patch.baseUrl !== undefined ? updated.base_url : entry.base_url, + }; + }), + ); + if (patch.approval !== undefined) patchConfig({ approval: patch.approval }); + }, + [client, patchConfig], + ); const setSessionLoadingState = useCallback((value: boolean) => { sessionLoadingRef.current = value; @@ -190,6 +239,27 @@ export default function App() { } }, [client]); + // Session result (final_answer / verification / failure). Same seq-guard + // pattern as refreshMemory; failures just keep the previous result hidden. + const refreshResult = useCallback(async (id: string, retryEmpty = false) => { + if (activeSessionRef.current !== id) return; + const resultSeq = ++resultSeqRef.current; + const delays = retryEmpty ? RESULT_RETRY_DELAYS_MS : RESULT_RETRY_DELAYS_MS.slice(0, 1); + for (const delay of delays) { + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); + if (activeSessionRef.current !== id || resultSeqRef.current !== resultSeq) return; + try { + const result = await client.result(id); + if (activeSessionRef.current !== id || resultSeqRef.current !== resultSeq) return; + setResultState(result); + if (hasResultContent(result)) return; + } catch { + // A failed refresh keeps the previous result rather than clobbering it. + return; + } + } + }, [client]); + useEffect(() => { void refreshSessions(); // Config edits can pass through incomplete URLs; refresh explicitly after edits. @@ -228,6 +298,8 @@ export default function App() { setItems([]); setDiffState(null); setMemoryState(null); + setResultState(null); + setRailPanel(null); setStatus("idle"); void refreshSessions(); void refreshMemory(id); @@ -263,6 +335,8 @@ export default function App() { setItems([]); setDiffState(null); setMemoryState(null); + setResultState(null); + setRailPanel(null); setStreaming(false); setSessionLoadingState(false); setStatus("no session"); @@ -300,8 +374,9 @@ export default function App() { if (followAbortRef.current === ctrl) followAbortRef.current = null; void refreshSessions(); void refreshMemory(id); + void refreshResult(id); } - }, [client, refreshSessions, refreshMemory]); + }, [client, refreshSessions, refreshMemory, refreshResult]); const loadSession = useCallback(async (id: string) => { const loadSeq = loadSeqRef.current + 1; @@ -318,6 +393,8 @@ export default function App() { setItems([]); setDiffState(null); setMemoryState(null); + setResultState(null); + setRailPanel(null); setStatus("loading history…"); try { const [summary, events] = await Promise.all([ @@ -331,6 +408,7 @@ export default function App() { setItems(replayEvents([], events)); setStatus(summary.turn_in_progress ? "reconnected" : "idle"); void refreshMemory(id); + void refreshResult(id); if (summary.turn_in_progress) void followSession(id); } catch (e) { if (loadSeqRef.current !== loadSeq || activeSessionRef.current !== id) return; @@ -347,7 +425,7 @@ export default function App() { // shows an error (or recovered data) instead of loading forever. void refreshMemory(id); } - }, [client, followSession, refreshMemory, setSessionLoadingState]); + }, [client, followSession, refreshMemory, refreshResult, setSessionLoadingState]); async function reconcileSession(id: string, errorText: string) { try { @@ -422,21 +500,37 @@ export default function App() { abortRef.current = null; } void refreshSessions(); - if (activeSessionRef.current === sessionId) void refreshMemory(sessionId); + if (activeSessionRef.current === sessionId) { + void refreshMemory(sessionId); + // The stream can close just before terminal result details are durable. + void refreshResult(sessionId, true); + } } - }, [client, prompt, sessionId, sessionMode, streaming, refreshSessions, refreshMemory]); + }, [client, prompt, sessionId, sessionMode, streaming, refreshSessions, refreshMemory, refreshResult]); const onApprove = useCallback( - async (requestId: string, approved: boolean, feedback: string) => { + async ( + requestId: string, + approved: boolean, + feedback: string, + scope: ApprovalScope, + ) => { if (!sessionId) return; + const approvalSessionId = sessionId; setItems((prev) => resolveApproval(prev, requestId, approved ? "approved" : "denied"), ); try { - await client.approve(sessionId, requestId, approved, feedback); + await client.approve(approvalSessionId, requestId, approved, feedback, scope); } catch (e) { + if (activeSessionRef.current !== approvalSessionId) return; setItems((prev) => [ - ...prev, + // Roll back the optimistic resolve so the card is actionable again. + ...prev.map((it) => + it.kind === "approval" && it.requestId === requestId + ? { ...it, resolved: undefined } + : it, + ), { id: `e${Date.now()}`, kind: "error", @@ -450,6 +544,10 @@ export default function App() { const showDiff = useCallback(async () => { if (!sessionId) return; + // Skip work for sessions that are no longer active, and let only the + // latest fetch apply. Same seq-guard pattern as refreshMemory/refreshResult. + if (activeSessionRef.current !== sessionId) return; + const diffSeq = ++diffSeqRef.current; try { const d = await client.diff(sessionId); let patch: WorkspacePatch | null = null; @@ -458,8 +556,10 @@ export default function App() { } catch { patch = null; } + if (activeSessionRef.current !== sessionId || diffSeqRef.current !== diffSeq) return; setDiffState({ diff: d, patch }); } catch (e) { + if (activeSessionRef.current !== sessionId || diffSeqRef.current !== diffSeq) return; setItems((prev) => [ ...prev, { @@ -476,56 +576,161 @@ export default function App() { if (sessionId) void client.cancel(sessionId); }, [client, sessionId]); + const toggleRail = useCallback( + (panel: Exclude) => { + if (railPanel === panel) { + setRailPanel(null); + return; + } + setRailPanel(panel); + // The diff is fetched on demand when its panel opens; memory data is + // kept fresh by the normal session lifecycle. + if (panel === "diff") void showDiff(); + }, + [railPanel, showDiff], + ); + + // After a checkpoint restore, replay the restored session through the same + // load path used for session select, then reopen the checkpoints panel. + const reloadAfterRestore = useCallback(async (id: string) => { + if (activeSessionRef.current !== id) return; + await loadSession(id); + // loadSession no-ops when the user switched sessions mid-restore; don't + // reopen the checkpoints panel for a session that is no longer active. + if (activeSessionRef.current !== id) return; + setRailPanel("checkpoints"); + }, [loadSession]); + + const selectSession = useCallback( + (id: string) => { + // On small screens the sidebar is an overlay; close it after picking. + if ( + typeof window.matchMedia === "function" && + window.matchMedia("(max-width: 767px)").matches + ) { + setSidebarOpen(false); + } + void loadSession(id); + }, + [loadSession], + ); + + const activeSession = useMemo( + () => sessions.find((s) => s.session_id === sessionId) ?? null, + [sessions, sessionId], + ); + return ( -
-
+ {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + -
- +
setSidebarOpen((v) => !v)} + sessionId={sessionId} + status={status} + client={client} + theme={theme} + onToggleTheme={toggleTheme} + showThinking={showThinking} + onToggleThinking={toggleThinking} /> -
- - {diffState && ( +
+ {resultState && hasResultContent(resultState) && ( + + )} + + +
+
+ + {railPanel === "diff" ? ( + diffState ? ( setDiffState(null)} + onClose={() => { + setDiffState(null); + setRailPanel(null); + }} /> - )} - {sessionId && ( + ) : ( + + ) + ) : railPanel === "memory" ? ( + sessionId ? ( void refreshMemory(sessionId)} /> - )} - -
- + ) : ( + + ) + ) : railPanel === "checkpoints" ? ( + sessionId ? ( + reloadAfterRestore(sessionId)} + onCaptured={() => void refreshSessions()} + /> + ) : ( + + ) + ) : railPanel === "settings" ? ( + sessionId ? ( + updateSessionRuntime(sessionId, patch)} + /> + ) : ( + + ) + ) : null} + +
+ ); +} + +function RailPlaceholder({ text }: { text: string }) { + return ( +
+ {text}
); } diff --git a/coding-agent/webui/app/src/components/CheckpointsPanel.test.tsx b/coding-agent/webui/app/src/components/CheckpointsPanel.test.tsx new file mode 100644 index 000000000..94cfb03e8 --- /dev/null +++ b/coding-agent/webui/app/src/components/CheckpointsPanel.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AgentClient } from "../lib/api"; +import CheckpointsPanel from "./CheckpointsPanel"; +import type { CheckpointMetadata } from "../lib/types"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const checkpoint = (overrides: Partial = {}): CheckpointMetadata => ({ + checkpoint_id: "ckpt-0001", + tape_id: "tape-1", + session_id: "s1", + entry_count: 12, + window_start: 0, + created_at: "2026-06-12T10:00:00Z", + label: "before refactor", + ...overrides, +}); + +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + +const client = new AgentClient({ baseUrl: "http://127.0.0.1:18080" }); + +function stubFetch(handlers: { + list: () => CheckpointMetadata[]; + onCapture?: (body: Record) => void; + onRestore?: (checkpointId: string) => void; +}) { + const calls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/checkpoints/") && url.endsWith("/restore") && init?.method === "POST") { + const checkpointId = url.split("/checkpoints/")[1].replace(/\/restore$/, ""); + calls.push(`restore:${checkpointId}`); + handlers.onRestore?.(checkpointId); + return Promise.resolve( + jsonResponse({ status: "restored", session_id: "s1", checkpoint_id: checkpointId }), + ); + } + if (url.endsWith("/sessions/s1/checkpoints") && init?.method === "POST") { + calls.push("capture"); + handlers.onCapture?.(JSON.parse(String(init.body)) as Record); + return Promise.resolve(jsonResponse(checkpoint({ checkpoint_id: "ckpt-0002" }))); + } + if (url.endsWith("/sessions/s1/checkpoints")) { + calls.push("list"); + return Promise.resolve(jsonResponse({ checkpoints: handlers.list() })); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + return calls; +} + +describe("CheckpointsPanel", () => { + it("lists checkpoints with label, id, and entry count", async () => { + stubFetch({ list: () => [checkpoint()] }); + + render( undefined} />); + + expect(await screen.findByText("before refactor")).toBeTruthy(); + expect(screen.getByText(/ckpt-0001 · 12 entries/)).toBeTruthy(); + expect(screen.getByText("Checkpoints · 1")).toBeTruthy(); + }); + + it("creates a checkpoint with an optional label and refreshes the list", async () => { + const captured: Array> = []; + const items: CheckpointMetadata[] = []; + const onCaptured = vi.fn(); + const calls = stubFetch({ + list: () => items, + onCapture: (body) => { + captured.push(body); + items.push(checkpoint({ checkpoint_id: "ckpt-0002", label: body.label as string })); + }, + }); + + render( + undefined} + onCaptured={onCaptured} + />, + ); + + expect(await screen.findByText("No checkpoints")).toBeTruthy(); + fireEvent.change(screen.getByLabelText("checkpoint label"), { + target: { value: "pre-migration" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Create checkpoint" })); + + await waitFor(() => expect(captured).toEqual([{ label: "pre-migration" }])); + expect(await screen.findByText("pre-migration")).toBeTruthy(); + expect(onCaptured).toHaveBeenCalledTimes(1); + expect(calls.filter((c) => c === "list")).toHaveLength(2); + }); + + it("requires confirmation before restoring and reloads via onRestored", async () => { + const restored: string[] = []; + const onRestored = vi.fn(); + stubFetch({ list: () => [checkpoint()], onRestore: (id) => restored.push(id) }); + + render(); + + expect(await screen.findByText("before refactor")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Restore" })); + + // Confirm step states the exact restore boundary before anything is posted. + expect(screen.getByText(/rewinds session history and runtime settings/i)).toBeTruthy(); + expect(screen.getByText(/workspace files are not restored/i)).toBeTruthy(); + expect(restored).toHaveLength(0); + + fireEvent.click(screen.getByRole("button", { name: "Confirm restore" })); + + await waitFor(() => expect(restored).toEqual(["ckpt-0001"])); + await waitFor(() => expect(onRestored).toHaveBeenCalledTimes(1)); + }); + + it("cancelling the confirm step posts nothing", async () => { + const restored: string[] = []; + stubFetch({ list: () => [checkpoint()], onRestore: (id) => restored.push(id) }); + + render( undefined} />); + + expect(await screen.findByText("before refactor")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Restore" })); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(screen.queryByText(/workspace files are not restored/i)).toBeNull(); + expect(restored).toHaveLength(0); + expect(screen.getByRole("button", { name: "Restore" })).toBeTruthy(); + }); +}); diff --git a/coding-agent/webui/app/src/components/CheckpointsPanel.tsx b/coding-agent/webui/app/src/components/CheckpointsPanel.tsx new file mode 100644 index 000000000..102f635f2 --- /dev/null +++ b/coding-agent/webui/app/src/components/CheckpointsPanel.tsx @@ -0,0 +1,173 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { AgentClient } from "../lib/api"; +import type { CheckpointMetadata } from "../lib/types"; + +interface Props { + client: AgentClient; + sessionId: string; + // Called after a successful restore so the app can reload session state. + onRestored: () => void | Promise; + // Called after a successful capture so the sidebar badge can refresh. + onCaptured?: () => void; +} + +// Checkpoint capture/restore panel +// (POST/GET /sessions/{id}/checkpoints, POST .../{checkpoint_id}/restore). +export default function CheckpointsPanel({ client, sessionId, onRestored, onCaptured }: Props) { + const [checkpoints, setCheckpoints] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [label, setLabel] = useState(""); + const [creating, setCreating] = useState(false); + const [confirmingId, setConfirmingId] = useState(null); + const [restoringId, setRestoringId] = useState(null); + const seqRef = useRef(0); + + const load = useCallback(async () => { + const seq = ++seqRef.current; + setLoading(true); + setError(null); + try { + const list = await client.listCheckpoints(sessionId); + if (seqRef.current !== seq) return; + setCheckpoints(list); + setLoading(false); + } catch (e) { + if (seqRef.current !== seq) return; + setLoading(false); + setError(`checkpoints load failed: ${e instanceof Error ? e.message : String(e)}`); + } + }, [client, sessionId]); + + useEffect(() => { + void load(); + }, [load]); + + const create = async () => { + setCreating(true); + setError(null); + try { + await client.captureCheckpoint(sessionId, label); + setLabel(""); + await load(); + onCaptured?.(); + } catch (e) { + setError(`checkpoint failed: ${e instanceof Error ? e.message : String(e)}`); + } finally { + setCreating(false); + } + }; + + const restore = async (checkpointId: string) => { + setRestoringId(checkpointId); + setError(null); + try { + await client.restoreCheckpoint(sessionId, checkpointId); + setConfirmingId(null); + await onRestored(); + } catch (e) { + setConfirmingId(null); + setError(`restore failed: ${e instanceof Error ? e.message : String(e)}`); + } finally { + setRestoringId(null); + } + }; + + return ( +
+
+
+ Checkpoints · {checkpoints.length} +
+ {error &&
{error}
} +
+
+ setLabel(e.target.value)} + /> + +
+
+ {loading ? ( +
Loading checkpoints…
+ ) : checkpoints.length === 0 ? ( +
No checkpoints
+ ) : ( + checkpoints.map((checkpoint) => ( +
+
+ + {checkpoint.label?.trim() || checkpoint.checkpoint_id} + + {formatTime(checkpoint.created_at)} + {confirmingId !== checkpoint.checkpoint_id && ( + + )} +
+
+ {checkpoint.checkpoint_id} · {checkpoint.entry_count}{" "} + {checkpoint.entry_count === 1 ? "entry" : "entries"} +
+ {confirmingId === checkpoint.checkpoint_id && ( +
+
+ Restore rewinds session history and runtime settings to this checkpoint. + Workspace files are not restored; inspect or save the workspace diff first. +
+
+ + +
+
+ )} +
+ )) + )} +
+
+ ); +} + +function formatTime(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(undefined, { + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} diff --git a/coding-agent/webui/app/src/components/Composer.tsx b/coding-agent/webui/app/src/components/Composer.tsx index cf8e1ff7b..0e5be3555 100644 --- a/coding-agent/webui/app/src/components/Composer.tsx +++ b/coding-agent/webui/app/src/components/Composer.tsx @@ -16,9 +16,9 @@ export default function Composer({ streaming, }: Props) { return ( -