From de30c92daa7921acdf0a01e17e9ff4e5c0147343 Mon Sep 17 00:00:00 2001 From: kina0630 Date: Thu, 30 Jul 2026 15:07:25 +0800 Subject: [PATCH 1/2] feat(webui): redesign agent workspace Add the right-side operational rail, checkpoint and result views, memory review controls, expanded timeline events, and responsive workspace layout. Keep asynchronous session updates scoped to their originating session and cover the new behavior with focused UI tests. --- coding-agent/webui/app/src/App.test.tsx | 968 +++++++++++++++++- coding-agent/webui/app/src/App.tsx | 313 ++++-- .../src/components/CheckpointsPanel.test.tsx | 145 +++ .../app/src/components/CheckpointsPanel.tsx | 173 ++++ .../webui/app/src/components/Composer.tsx | 8 +- .../webui/app/src/components/DiffPanel.tsx | 10 +- .../webui/app/src/components/Header.tsx | 116 ++- .../webui/app/src/components/MemoryPanel.tsx | 28 +- .../app/src/components/ResultPanel.test.tsx | 76 ++ .../webui/app/src/components/ResultPanel.tsx | 69 ++ .../app/src/components/ReviewQueue.test.tsx | 240 +++++ .../webui/app/src/components/ReviewQueue.tsx | 284 +++++ .../webui/app/src/components/RightRail.tsx | 110 ++ .../app/src/components/SessionList.test.tsx | 87 ++ .../webui/app/src/components/SessionList.tsx | 81 +- .../app/src/components/SettingsPanel.test.tsx | 120 +++ .../app/src/components/SettingsPanel.tsx | 196 ++++ .../app/src/components/Timeline.test.tsx | 255 +++++ .../webui/app/src/components/Timeline.tsx | 247 ++++- coding-agent/webui/app/src/index.css | 27 +- coding-agent/webui/app/src/lib/api.ts | 93 +- .../webui/app/src/lib/timeline.test.ts | 65 +- coding-agent/webui/app/src/lib/timeline.ts | 70 +- coding-agent/webui/app/src/lib/types.ts | 24 + 24 files changed, 3603 insertions(+), 202 deletions(-) create mode 100644 coding-agent/webui/app/src/components/CheckpointsPanel.test.tsx create mode 100644 coding-agent/webui/app/src/components/CheckpointsPanel.tsx create mode 100644 coding-agent/webui/app/src/components/ResultPanel.test.tsx create mode 100644 coding-agent/webui/app/src/components/ResultPanel.tsx create mode 100644 coding-agent/webui/app/src/components/ReviewQueue.test.tsx create mode 100644 coding-agent/webui/app/src/components/ReviewQueue.tsx create mode 100644 coding-agent/webui/app/src/components/RightRail.tsx create mode 100644 coding-agent/webui/app/src/components/SessionList.test.tsx create mode 100644 coding-agent/webui/app/src/components/SettingsPanel.test.tsx create mode 100644 coding-agent/webui/app/src/components/SettingsPanel.tsx create mode 100644 coding-agent/webui/app/src/components/Timeline.test.tsx diff --git a/coding-agent/webui/app/src/App.test.tsx b/coding-agent/webui/app/src/App.test.tsx index 95fd5b761..031d535e0 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,963 @@ 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("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..006e4a3ce 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,14 @@ 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"; type Theme = "dark" | "light"; type Config = { baseUrl: string; @@ -73,6 +80,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 +117,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 +148,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 +169,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 +238,21 @@ 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) => { + if (activeSessionRef.current !== id) return; + const resultSeq = ++resultSeqRef.current; + try { + const result = await client.result(id); + if (activeSessionRef.current !== id || resultSeqRef.current !== resultSeq) return; + setResultState(result); + } 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 +291,8 @@ export default function App() { setItems([]); setDiffState(null); setMemoryState(null); + setResultState(null); + setRailPanel(null); setStatus("idle"); void refreshSessions(); void refreshMemory(id); @@ -263,6 +328,8 @@ export default function App() { setItems([]); setDiffState(null); setMemoryState(null); + setResultState(null); + setRailPanel(null); setStreaming(false); setSessionLoadingState(false); setStatus("no session"); @@ -300,8 +367,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 +386,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 +401,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 +418,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 +493,36 @@ export default function App() { abortRef.current = null; } void refreshSessions(); - if (activeSessionRef.current === sessionId) void refreshMemory(sessionId); + if (activeSessionRef.current === sessionId) { + void refreshMemory(sessionId); + void refreshResult(sessionId); + } } - }, [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 +536,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 +548,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 +568,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 ( -