+
{diff.files.length === 0 ? (
No changes
) : (
@@ -31,7 +31,7 @@ export default function DiffPanel({ diff, patch, onClose }: Props) {
className="grid grid-cols-[4.5rem_1fr] gap-2 border-b border-border px-4 py-2 text-xs"
>
{file.status}
-
+
{file.path}
@@ -44,7 +44,7 @@ export default function DiffPanel({ diff, patch, onClose }: Props) {
))
)}
-
+
{patch?.patch
? patch.patch.split("\n").map((line, index) => (
diff --git a/coding-agent/webui/app/src/components/Header.tsx b/coding-agent/webui/app/src/components/Header.tsx
index ab450f790..8459ebf96 100644
--- a/coding-agent/webui/app/src/components/Header.tsx
+++ b/coding-agent/webui/app/src/components/Header.tsx
@@ -12,7 +12,7 @@ interface Config {
}
// Mirrors ProviderName in src/coding_agent/server/schemas.py.
-const PROVIDERS = [
+export const PROVIDERS = [
"openai",
"anthropic",
"copilot",
@@ -31,24 +31,28 @@ interface Props {
config: Config;
onConfigChange: (patch: Partial
) => void;
onNewSession: () => void;
- onShowDiff: () => void;
+ onToggleSidebar: () => void;
sessionId: string | null;
status: string;
client: AgentClient;
theme: "dark" | "light";
onToggleTheme: () => void;
+ showThinking: boolean;
+ onToggleThinking: () => void;
}
export default function Header({
config,
onConfigChange,
onNewSession,
- onShowDiff,
+ onToggleSidebar,
sessionId,
status,
client,
theme,
onToggleTheme,
+ showThinking,
+ onToggleThinking,
}: Props) {
// Live model ids for the selected provider; null = use MODEL_PRESETS.
const [liveModels, setLiveModels] = useState(null);
@@ -78,65 +82,70 @@ export default function Header({
const modelOptions = liveModels ?? MODEL_PRESETS;
return (
-
+
diff --git a/coding-agent/webui/app/src/components/MemoryPanel.tsx b/coding-agent/webui/app/src/components/MemoryPanel.tsx
index dde906763..b8241c320 100644
--- a/coding-agent/webui/app/src/components/MemoryPanel.tsx
+++ b/coding-agent/webui/app/src/components/MemoryPanel.tsx
@@ -1,15 +1,22 @@
+import type { AgentClient } from "../lib/api";
import type {
ContextPack,
ContextPackItem,
MemoryReviewRecord,
RuntimeRun,
} from "../lib/types";
+import ReviewQueue from "./ReviewQueue";
interface Props {
hits: ContextPackItem[];
memories: MemoryReviewRecord[];
loading: boolean;
error: string | null;
+ // When provided, the static accepted list is replaced by the interactive
+ // review queue (status tabs + accept/reject transitions).
+ client?: AgentClient;
+ sessionId?: string;
+ onReviewsChanged?: () => void;
}
// Flatten every run's metadata.context_pack sections into a single hit list.
@@ -29,9 +36,17 @@ export function extractRecallHits(runs: RuntimeRun[]): ContextPackItem[] {
return hits;
}
-export default function MemoryPanel({ hits, memories, loading, error }: Props) {
+export default function MemoryPanel({
+ hits,
+ memories,
+ loading,
+ error,
+ client,
+ sessionId,
+ onReviewsChanged,
+}: Props) {
return (
-
+
Memory · {hits.length} recall {hits.length === 1 ? "hit" : "hits"} ·{" "}
@@ -39,8 +54,8 @@ export default function MemoryPanel({ hits, memories, loading, error }: Props) {
{error &&
{error}
}
-
-
+
+
Recall hits
@@ -70,7 +85,10 @@ export default function MemoryPanel({ hits, memories, loading, error }: Props) {
))
)}
-
+ {client && sessionId && onReviewsChanged && (
+
+ )}
+
Accepted memories
diff --git a/coding-agent/webui/app/src/components/ResultPanel.test.tsx b/coding-agent/webui/app/src/components/ResultPanel.test.tsx
new file mode 100644
index 000000000..f9b749dac
--- /dev/null
+++ b/coding-agent/webui/app/src/components/ResultPanel.test.tsx
@@ -0,0 +1,76 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+import ResultPanel, { hasResultContent } from "./ResultPanel";
+import type { SessionResult } from "../lib/types";
+
+afterEach(cleanup);
+
+const result = (overrides: Partial
= {}): SessionResult => ({
+ session_id: "s1",
+ status: "completed",
+ turn_status: "idle",
+ provider_name: "kimi-code",
+ model_name: "kimi-for-coding",
+ final_answer: "All **58 tests** pass.",
+ verification_summary: "vitest + tsc green",
+ failure_details: null,
+ ...overrides,
+});
+
+describe("ResultPanel", () => {
+ it("renders the final answer as markdown with verification summary", () => {
+ render( );
+
+ expect(screen.getByText("Session result")).toBeTruthy();
+ // Markdown: bold segments render as .
+ expect(screen.getByText("58 tests").tagName).toBe("STRONG");
+ expect(screen.getByText(/vitest \+ tsc green/)).toBeTruthy();
+ expect(screen.getByText("kimi-code / kimi-for-coding")).toBeTruthy();
+ });
+
+ it("renders failure details with error styling", () => {
+ render(
+ ,
+ );
+
+ const failure = screen.getByText("step 12: bash exited 1");
+ expect(failure.className).toContain("text-err");
+ });
+
+ it("collapses and expands via the header", () => {
+ render( );
+
+ const toggle = screen.getByRole("button", { name: /session result/i });
+ expect(toggle.getAttribute("aria-expanded")).toBe("true");
+ fireEvent.click(toggle);
+
+ expect(toggle.getAttribute("aria-expanded")).toBe("false");
+ expect(screen.queryByText(/58 tests/)).toBeNull();
+
+ fireEvent.click(toggle);
+ expect(screen.getByText("58 tests")).toBeTruthy();
+ });
+});
+
+describe("hasResultContent", () => {
+ it("is false when every content field is empty", () => {
+ expect(
+ hasResultContent(
+ result({ final_answer: null, verification_summary: null, failure_details: null }),
+ ),
+ ).toBe(false);
+ expect(
+ hasResultContent(result({ final_answer: " ", verification_summary: "", failure_details: null })),
+ ).toBe(false);
+ });
+
+ it("is true when any content field is present", () => {
+ expect(
+ hasResultContent(result({ final_answer: null, failure_details: "boom" })),
+ ).toBe(true);
+ });
+});
diff --git a/coding-agent/webui/app/src/components/ResultPanel.tsx b/coding-agent/webui/app/src/components/ResultPanel.tsx
new file mode 100644
index 000000000..d9c4da83b
--- /dev/null
+++ b/coding-agent/webui/app/src/components/ResultPanel.tsx
@@ -0,0 +1,69 @@
+import { useState } from "react";
+import ReactMarkdown from "react-markdown";
+import type { SessionResult } from "../lib/types";
+
+interface Props {
+ result: SessionResult;
+}
+
+// True when GET /sessions/{id}/result has anything worth showing.
+export function hasResultContent(result: SessionResult): boolean {
+ return Boolean(
+ result.final_answer?.trim() ||
+ result.verification_summary?.trim() ||
+ result.failure_details?.trim(),
+ );
+}
+
+// Collapsible session result block pinned above the timeline.
+export default function ResultPanel({ result }: Props) {
+ const [open, setOpen] = useState(true);
+ const failed = Boolean(result.failure_details?.trim());
+ const meta = [result.provider_name, result.model_name].filter(Boolean).join(" / ");
+
+ return (
+
+
+
setOpen((v) => !v)}
+ >
+
+
+ Session result
+
+ {result.status}
+ {meta && {meta} }
+
+ {open ? "▾" : "▸"}
+
+
+ {open && (
+
+ {result.final_answer?.trim() && (
+
+ {result.final_answer}
+
+ )}
+ {result.verification_summary?.trim() && (
+
+ verification
+ {result.verification_summary}
+
+ )}
+ {result.failure_details?.trim() && (
+
+ {result.failure_details}
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/coding-agent/webui/app/src/components/ReviewQueue.test.tsx b/coding-agent/webui/app/src/components/ReviewQueue.test.tsx
new file mode 100644
index 000000000..1e314304b
--- /dev/null
+++ b/coding-agent/webui/app/src/components/ReviewQueue.test.tsx
@@ -0,0 +1,240 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { AgentClient } from "../lib/api";
+import ReviewQueue from "./ReviewQueue";
+import type { MemoryReviewRecord } from "../lib/types";
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+const record = (overrides: Partial = {}): MemoryReviewRecord => ({
+ candidate_id: "cand-1",
+ status: "candidate",
+ review_reason: null,
+ kind: "fact",
+ title: "Project uses uv",
+ summary: "The project standardizes on uv for dependency management.",
+ scope: "project",
+ tags: ["python"],
+ confidence: 0.92,
+ topic_id: null,
+ session_id: "s1",
+ tape_id: null,
+ ...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: {
+ lists: Record;
+ onPost?: (candidateId: string, body: Record) => void;
+}) {
+ const listCalls: string[] = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (init?.method === "POST" && url.includes("/memory/reviews/")) {
+ const candidateId = url.split("/memory/reviews/")[1];
+ handlers.onPost?.(candidateId, JSON.parse(String(init.body)) as Record);
+ return Promise.resolve(jsonResponse({ candidate_id: candidateId, status: "accepted" }));
+ }
+ if (url.includes("/memory/reviews")) {
+ const status = new URL(url).searchParams.get("status") ?? "";
+ listCalls.push(status);
+ return Promise.resolve(jsonResponse(handlers.lists[status] ?? []));
+ }
+ throw new Error(`unexpected fetch ${url}`);
+ }),
+ );
+ return listCalls;
+}
+
+describe("ReviewQueue", () => {
+ it("defaults to candidates and accepts a candidate, then refreshes the list", async () => {
+ const posted: Array<{ candidateId: string; body: Record }> = [];
+ const onChanged = vi.fn();
+ const lists: Record = { candidate: [record()] };
+ const listCalls = stubFetch({
+ lists,
+ onPost: (candidateId, body) => {
+ posted.push({ candidateId, body });
+ lists.candidate = [];
+ },
+ });
+
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /review queue/i }));
+
+ expect(await screen.findByText("Project uses uv")).toBeTruthy();
+ expect(screen.getByRole("tab", { name: "Candidates" }).getAttribute("aria-selected")).toBe(
+ "true",
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Accept" }));
+
+ await waitFor(() => expect(posted).toHaveLength(1));
+ expect(posted[0]).toEqual({
+ candidateId: "cand-1",
+ body: { status: "accepted", reason: null },
+ });
+ await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
+ // The candidate list was refetched after the transition and is now empty.
+ await waitFor(() => expect(listCalls.filter((s) => s === "candidate").length).toBe(2));
+ expect(await screen.findByText("No candidates memories")).toBeTruthy();
+ expect(screen.queryByText("Project uses uv")).toBeNull();
+ });
+
+ it("rejects a candidate with an optional reason via the inline prompt", async () => {
+ const posted: Array<{ candidateId: string; body: Record }> = [];
+ const onChanged = vi.fn();
+ const lists: Record = { candidate: [record()] };
+ stubFetch({
+ lists,
+ onPost: (candidateId, body) => {
+ posted.push({ candidateId, body });
+ lists.candidate = [];
+ },
+ });
+
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /review queue/i }));
+
+ expect(await screen.findByText("Project uses uv")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Reject" }));
+ expect(screen.queryByText("Project uses uv")).toBeTruthy();
+ expect(posted).toHaveLength(0);
+
+ fireEvent.change(screen.getByLabelText("reject reason"), {
+ target: { value: "outdated advice" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Confirm reject" }));
+
+ await waitFor(() => expect(posted).toHaveLength(1));
+ expect(posted[0]).toEqual({
+ candidateId: "cand-1",
+ body: { status: "rejected", reason: "outdated advice" },
+ });
+ await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
+ expect(await screen.findByText("No candidates memories")).toBeTruthy();
+ });
+
+ it("cancelling the inline reject prompt posts nothing", async () => {
+ const onChanged = vi.fn();
+ stubFetch({ lists: { candidate: [record()] } });
+
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /review queue/i }));
+
+ expect(await screen.findByText("Project uses uv")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Reject" }));
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(screen.queryByLabelText("reject reason")).toBeNull();
+ expect(onChanged).not.toHaveBeenCalled();
+ expect(screen.getByText("Project uses uv")).toBeTruthy();
+ });
+
+ it("falls back to the accepted tab when no candidates exist", async () => {
+ const listCalls = stubFetch({
+ lists: {
+ candidate: [],
+ accepted: [record({ candidate_id: "cand-9", status: "accepted", title: "Kept memory" })],
+ },
+ });
+
+ render( undefined} />);
+ fireEvent.click(screen.getByRole("button", { name: /review queue/i }));
+
+ expect(await screen.findByText("Kept memory")).toBeTruthy();
+ expect(screen.getByRole("tab", { name: "Accepted" }).getAttribute("aria-selected")).toBe(
+ "true",
+ );
+ expect(listCalls).toEqual(["candidate", "accepted"]);
+ });
+
+ it("loads the selected status when switching tabs", async () => {
+ const listCalls = stubFetch({
+ lists: {
+ candidate: [record()],
+ rejected: [
+ record({
+ candidate_id: "cand-7",
+ status: "rejected",
+ title: "Dropped memory",
+ review_reason: "stale",
+ }),
+ ],
+ },
+ });
+
+ render( undefined} />);
+ fireEvent.click(screen.getByRole("button", { name: /review queue/i }));
+
+ expect(await screen.findByText("Project uses uv")).toBeTruthy();
+ fireEvent.click(screen.getByRole("tab", { name: "Rejected" }));
+
+ expect(await screen.findByText("Dropped memory")).toBeTruthy();
+ expect(screen.getByText("reason: stale")).toBeTruthy();
+ // Rejected rows carry no accept/reject actions.
+ expect(screen.queryByRole("button", { name: "Accept" })).toBeNull();
+ expect(listCalls).toEqual(["candidate", "rejected"]);
+ });
+
+ it("refreshes the current tab, not the stale one, when the user switches tabs mid-transition", async () => {
+ let resolvePost: (() => void) | null = null;
+ const lists: Record = {
+ candidate: [record()],
+ accepted: [record({ candidate_id: "cand-2", status: "accepted", title: "Kept memory" })],
+ };
+ const listCalls: string[] = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (init?.method === "POST" && url.includes("/memory/reviews/")) {
+ return new Promise((resolve) => {
+ resolvePost = () =>
+ resolve(jsonResponse({ candidate_id: "cand-1", status: "accepted" }));
+ });
+ }
+ if (url.includes("/memory/reviews")) {
+ const status = new URL(url).searchParams.get("status") ?? "";
+ listCalls.push(status);
+ return Promise.resolve(jsonResponse(lists[status] ?? []));
+ }
+ throw new Error(`unexpected fetch ${url}`);
+ }),
+ );
+
+ render( undefined} />);
+ fireEvent.click(screen.getByRole("button", { name: /review queue/i }));
+ expect(await screen.findByText("Project uses uv")).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: "Accept" }));
+ // Switch tabs while the transition POST is still in flight.
+ fireEvent.click(screen.getByRole("tab", { name: "Accepted" }));
+ expect(await screen.findByText("Kept memory")).toBeTruthy();
+
+ expect(resolvePost).not.toBeNull();
+ await act(async () => {
+ resolvePost!();
+ });
+
+ // The post-transition reload targeted the accepted tab, and the stale
+ // candidate list never overwrote the accepted records.
+ await waitFor(() => expect(listCalls[listCalls.length - 1]).toBe("accepted"));
+ expect(screen.getByText("Kept memory")).toBeTruthy();
+ expect(screen.queryByText("Project uses uv")).toBeNull();
+ });
+});
diff --git a/coding-agent/webui/app/src/components/ReviewQueue.tsx b/coding-agent/webui/app/src/components/ReviewQueue.tsx
new file mode 100644
index 000000000..5c6ec2898
--- /dev/null
+++ b/coding-agent/webui/app/src/components/ReviewQueue.tsx
@@ -0,0 +1,284 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { AgentClient } from "../lib/api";
+import type { MemoryReviewRecord } from "../lib/types";
+
+type ReviewStatus = "candidate" | "accepted" | "rejected" | "archived";
+
+const TABS: Array<{ id: ReviewStatus; label: string }> = [
+ { id: "candidate", label: "Candidates" },
+ { id: "accepted", label: "Accepted" },
+ { id: "rejected", label: "Rejected" },
+ { id: "archived", label: "Archived" },
+];
+
+interface Props {
+ client: AgentClient;
+ sessionId: string;
+ // Called after a transition so the parent can refresh its accepted list.
+ onChanged: () => void;
+}
+
+// Reviewed-memory curation queue (GET/POST /sessions/{id}/memory/reviews).
+// Collapsed by default; the first expand loads candidates (or accepted when
+// no candidates exist) so a passive panel open costs no extra requests.
+export default function ReviewQueue({ client, sessionId, onChanged }: Props) {
+ const [expanded, setExpanded] = useState(false);
+ const [tab, setTab] = useState("candidate");
+ const [records, setRecords] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [rejectingId, setRejectingId] = useState(null);
+ const [reason, setReason] = useState("");
+ const [busyId, setBusyId] = useState(null);
+ const seqRef = useRef(0);
+ const initializedRef = useRef(false);
+ // Mirror of `tab` for async callbacks: a transition started on one tab must
+ // reload whatever tab is current when its POST finishes, not the stale one.
+ const tabRef = useRef(tab);
+
+ const load = useCallback(
+ async (status: ReviewStatus): Promise => {
+ const seq = ++seqRef.current;
+ setLoading(true);
+ setError(null);
+ try {
+ const list = await client.listMemoryReviews(sessionId, status);
+ if (seqRef.current !== seq) return null;
+ setRecords(list);
+ setLoading(false);
+ return list;
+ } catch (e) {
+ if (seqRef.current !== seq) return null;
+ setRecords([]);
+ setLoading(false);
+ setError(`reviews load failed: ${e instanceof Error ? e.message : String(e)}`);
+ return null;
+ }
+ },
+ [client, sessionId],
+ );
+
+ // Initial load on first expand: candidates win; fall back to accepted when
+ // none exist.
+ useEffect(() => {
+ if (!expanded || initializedRef.current) return;
+ initializedRef.current = true;
+ let cancelled = false;
+ void (async () => {
+ const candidates = await load("candidate");
+ if (cancelled || candidates === null) return;
+ if (candidates.length === 0) {
+ tabRef.current = "accepted";
+ setTab("accepted");
+ await load("accepted");
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [expanded, load]);
+
+ const selectTab = (status: ReviewStatus) => {
+ if (status === tab) return;
+ tabRef.current = status;
+ setTab(status);
+ setRejectingId(null);
+ setReason("");
+ void load(status);
+ };
+
+ const transition = async (
+ candidateId: string,
+ status: "accepted" | "rejected",
+ reasonText?: string,
+ ) => {
+ setBusyId(candidateId);
+ setError(null);
+ try {
+ await client.transitionMemoryReview(sessionId, candidateId, status, reasonText);
+ setRejectingId(null);
+ setReason("");
+ // Reload the tab that is current now — the user may have switched tabs
+ // while the POST was in flight.
+ await load(tabRef.current);
+ onChanged();
+ } catch (e) {
+ setError(`review update failed: ${e instanceof Error ? e.message : String(e)}`);
+ } finally {
+ setBusyId(null);
+ }
+ };
+
+ return (
+
+
setExpanded((v) => !v)}
+ >
+ {expanded ? "▾" : "▸"}
+ Review queue
+
+ {expanded && (
+ <>
+
+ {TABS.map((t) => (
+ selectTab(t.id)}
+ >
+ {t.label}
+
+ ))}
+
+ {error && (
+
+ {error}
+
+ )}
+ {loading ? (
+
Loading reviews…
+ ) : records.length === 0 ? (
+
+ No {TABS.find((t) => t.id === tab)?.label.toLowerCase()} memories
+
+ ) : (
+ records.map((record) => (
+
void transition(record.candidate_id, "accepted")}
+ onRejectStart={() => {
+ setRejectingId(record.candidate_id);
+ setReason("");
+ }}
+ onRejectConfirm={() => void transition(record.candidate_id, "rejected", reason)}
+ onRejectCancel={() => {
+ setRejectingId(null);
+ setReason("");
+ }}
+ />
+ ))
+ )}
+ >
+ )}
+
+ );
+}
+
+function ReviewRow({
+ record,
+ actionable,
+ busy,
+ rejecting,
+ reason,
+ onReasonChange,
+ onAccept,
+ onRejectStart,
+ onRejectConfirm,
+ onRejectCancel,
+}: {
+ record: MemoryReviewRecord;
+ actionable: boolean;
+ busy: boolean;
+ rejecting: boolean;
+ reason: string;
+ onReasonChange: (value: string) => void;
+ onAccept: () => void;
+ onRejectStart: () => void;
+ onRejectConfirm: () => void;
+ onRejectCancel: () => void;
+}) {
+ return (
+
+
+
+ {record.title}
+
+
+ {record.kind}
+
+ conf {record.confidence.toFixed(2)}
+
+
{record.summary}
+ {record.review_reason && (
+
reason: {record.review_reason}
+ )}
+ {record.tags.length > 0 && (
+
+ {record.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+ {actionable && !rejecting && (
+
+
+ Accept
+
+
+ Reject
+
+
+ )}
+ {actionable && rejecting && (
+
+ onReasonChange(e.target.value)}
+ />
+
+ Confirm reject
+
+
+ Cancel
+
+
+ )}
+
+ );
+}
diff --git a/coding-agent/webui/app/src/components/RightRail.tsx b/coding-agent/webui/app/src/components/RightRail.tsx
new file mode 100644
index 000000000..70f8f4a3b
--- /dev/null
+++ b/coding-agent/webui/app/src/components/RightRail.tsx
@@ -0,0 +1,110 @@
+import type { ReactNode } from "react";
+
+export type RailPanel = "diff" | "memory" | "checkpoints" | "settings" | null;
+
+interface Props {
+ panel: RailPanel;
+ onToggle: (panel: Exclude) => void;
+ children: ReactNode;
+}
+
+// Vertical icon strip plus one expandable panel. Below md the panel becomes
+// an overlay drawer pinned left of the icon strip; on md+ it expands inline.
+export default function RightRail({ panel, onToggle, children }: Props) {
+ return (
+ <>
+ {panel && (
+ onToggle(panel)}
+ />
+ )}
+ {panel && (
+
+ )}
+
+ onToggle("diff")}
+ icon={
+
+
+
+
+
+ }
+ />
+ onToggle("memory")}
+ icon={
+
+
+
+
+
+ }
+ />
+ onToggle("checkpoints")}
+ icon={
+
+
+
+ }
+ />
+ onToggle("settings")}
+ icon={
+
+
+
+
+ }
+ />
+
+ >
+ );
+}
+
+function RailButton({
+ label,
+ active,
+ onClick,
+ icon,
+}: {
+ label: string;
+ active: boolean;
+ onClick: () => void;
+ icon: ReactNode;
+}) {
+ return (
+
+ {icon}
+
+ );
+}
diff --git a/coding-agent/webui/app/src/components/SessionList.test.tsx b/coding-agent/webui/app/src/components/SessionList.test.tsx
new file mode 100644
index 000000000..85e8fe4cf
--- /dev/null
+++ b/coding-agent/webui/app/src/components/SessionList.test.tsx
@@ -0,0 +1,87 @@
+// @vitest-environment jsdom
+
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+import SessionList from "./SessionList";
+import type { SessionSummary } from "../lib/types";
+
+afterEach(cleanup);
+
+const session = (
+ id: string,
+ overrides: Partial
= {},
+): SessionSummary => ({
+ session_id: id,
+ id,
+ status: "completed",
+ turn_status: "idle",
+ turn_id: null,
+ created_at: "2026-06-12T00:00:00Z",
+ updated_at: "2026-06-12T00:00:00Z",
+ last_activity: "2026-06-12T00:00:00Z",
+ turn_in_progress: false,
+ pending_approval: false,
+ provider_name: "test",
+ model_name: "model-x",
+ base_url: null,
+ max_steps: 12,
+ origin: null,
+ default_run_target: {},
+ workspace_id: null,
+ resumable: true,
+ last_run_id: null,
+ last_run_status: null,
+ last_interrupted_run_id: null,
+ resume_from_event_id: null,
+ checkpoint_count: 0,
+ latest_checkpoint_id: null,
+ latest_checkpoint_label: null,
+ ...overrides,
+});
+
+function renderList(sessions: SessionSummary[]) {
+ return render(
+ undefined}
+ onSelect={() => undefined}
+ onDelete={() => undefined}
+ onNewSession={() => undefined}
+ />,
+ );
+}
+
+describe("SessionList status dots", () => {
+ it("maps session states to dot color semantics", () => {
+ renderList([
+ session("session-running-0001", { status: "running" }),
+ session("session-pending-0001", { status: "waiting_approval", pending_approval: true }),
+ session("session-failed-0001", { status: "failed" }),
+ session("session-done-0001", { status: "completed" }),
+ ]);
+
+ const running = screen.getByTitle("status: running");
+ expect(running.className).toContain("bg-accent");
+ expect(running.className).toContain("animate-pulse");
+
+ expect(screen.getByTitle("status: pending").className).toContain("bg-warn");
+ expect(screen.getByTitle("status: failed").className).toContain("bg-err");
+ expect(screen.getByTitle("status: idle").className).toContain("bg-muted");
+ });
+});
+
+describe("SessionList checkpoint badge", () => {
+ it("shows the badge only when checkpoint_count is positive", () => {
+ renderList([
+ session("session-with-ckpt-01", { checkpoint_count: 3 }),
+ session("session-no-ckpt-0001"),
+ ]);
+
+ expect(screen.getByText("⎘ 3")).toBeTruthy();
+ expect(screen.queryByText("⎘ 0")).toBeNull();
+ });
+});
diff --git a/coding-agent/webui/app/src/components/SessionList.tsx b/coding-agent/webui/app/src/components/SessionList.tsx
index 5a3246730..0d7365feb 100644
--- a/coding-agent/webui/app/src/components/SessionList.tsx
+++ b/coding-agent/webui/app/src/components/SessionList.tsx
@@ -5,9 +5,11 @@ interface Props {
activeSessionId: string | null;
loading: boolean;
error: string | null;
+ open: boolean;
onRefresh: () => void;
onSelect: (sessionId: string) => void;
onDelete: (sessionId: string) => void;
+ onNewSession: () => void;
}
export default function SessionList({
@@ -15,23 +17,38 @@ export default function SessionList({
activeSessionId,
loading,
error,
+ open,
onRefresh,
onSelect,
onDelete,
+ onNewSession,
}: Props) {
return (
-
+
Sessions
-
- Refresh
-
+
+
+ + New
+
+
+ Refresh
+
+
{error && (
@@ -75,21 +92,29 @@ function SessionRow({
}`}
>
onSelect(session.session_id)}
>
-
+
{shortId(session.session_id)}
+ {session.checkpoint_count > 0 && (
+
+ ⎘ {session.checkpoint_count}
+
+ )}
{session.turn_in_progress && (
-
+
running
)}
-
+
{formatModel(session)}
@@ -113,6 +138,15 @@ function formatModel(session: SessionSummary) {
return parts.length ? parts.join(" / ") : "model ?";
}
+type StatusSemantic = "running" | "pending" | "failed" | "idle";
+
+function statusSemantic(status: SessionSummary["status"], pending: boolean): StatusSemantic {
+ if (pending) return "pending";
+ if (status === "failed") return "failed";
+ if (status === "running" || status === "waiting_approval") return "running";
+ return "idle";
+}
+
function StatusDot({
status,
pending,
@@ -120,16 +154,21 @@ function StatusDot({
status: SessionSummary["status"];
pending: boolean;
}) {
- const color = pending
- ? "bg-warn"
- : status === "failed"
- ? "bg-err"
- : status === "running"
- ? "bg-accent"
- : status === "completed"
- ? "bg-ok"
+ const semantic = statusSemantic(status, pending);
+ const color =
+ semantic === "pending"
+ ? "bg-warn"
+ : semantic === "failed"
+ ? "bg-err"
+ : semantic === "running"
+ ? "bg-accent animate-pulse"
: "bg-muted";
- return
;
+ return (
+
+ );
}
function shortId(id: string) {
diff --git a/coding-agent/webui/app/src/components/SettingsPanel.test.tsx b/coding-agent/webui/app/src/components/SettingsPanel.test.tsx
new file mode 100644
index 000000000..d19111cde
--- /dev/null
+++ b/coding-agent/webui/app/src/components/SettingsPanel.test.tsx
@@ -0,0 +1,120 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { RuntimeConfigPatch } from "../lib/api";
+import SettingsPanel from "./SettingsPanel";
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+function renderPanel(onUpdate: (patch: RuntimeConfigPatch) => Promise
) {
+ return render(
+ ,
+ );
+}
+
+describe("SettingsPanel", () => {
+ it("marks the thinking initial values as defaults, not the session's config", () => {
+ renderPanel(() => Promise.resolve());
+ expect(
+ screen.getByText(/Initial values are defaults, not read from this session/),
+ ).toBeTruthy();
+ });
+
+ it("does not present the new-session approval default as current session state", () => {
+ renderPanel(() => Promise.resolve());
+ expect((screen.getByTitle("session approval policy") as HTMLSelectElement).value).toBe("");
+ expect(
+ screen.getByText(
+ /current policy is not exposed by the server/i,
+ ),
+ ).toBeTruthy();
+ });
+
+ it("sends the user's last-known enabled state when only the effort changes", async () => {
+ const onUpdate = vi.fn(() => Promise.resolve());
+ renderPanel(onUpdate);
+
+ // Turn thinking off first, then change only the effort.
+ fireEvent.click(screen.getByTitle("thinking enabled"));
+ await waitFor(() =>
+ expect(onUpdate).toHaveBeenLastCalledWith({
+ thinking: { enabled: false, effort: "medium" },
+ }),
+ );
+
+ fireEvent.change(screen.getByTitle("thinking effort"), { target: { value: "high" } });
+ await waitFor(() =>
+ expect(onUpdate).toHaveBeenLastCalledWith({
+ thinking: { enabled: false, effort: "high" },
+ }),
+ );
+ expect(onUpdate).toHaveBeenCalledTimes(2);
+ });
+
+ it("serializes patches per field and never lets a stale success hide a later failure", async () => {
+ const deferreds: Array<{ resolve: () => void; reject: (e: Error) => void }> = [];
+ const onUpdate = vi.fn(
+ () =>
+ new Promise((resolve, reject) => {
+ deferreds.push({ resolve, reject });
+ }),
+ );
+ renderPanel(onUpdate);
+
+ const select = screen.getByTitle("session approval policy");
+ fireEvent.change(select, { target: { value: "yolo" } });
+ await waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(1));
+ expect(onUpdate).toHaveBeenLastCalledWith({ approval: "yolo" });
+
+ // A second change to the same control must queue behind the in-flight one.
+ fireEvent.change(select, { target: { value: "interactive" } });
+ await act(async () => undefined);
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ deferreds[0].resolve();
+ });
+ await waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(2));
+ expect(onUpdate).toHaveBeenLastCalledWith({ approval: "interactive" });
+ expect(await screen.findByText("Saved")).toBeTruthy();
+
+ // The later apply fails: its error feedback must win.
+ await act(async () => {
+ deferreds[1].reject(new Error("boom"));
+ });
+ expect(await screen.findByText("save failed: boom")).toBeTruthy();
+ expect(screen.queryByText("Saved")).toBeNull();
+ });
+
+ it("does not serialize different fields against each other", async () => {
+ const deferreds: Array<{ resolve: () => void; reject: (e: Error) => void }> = [];
+ const onUpdate = vi.fn(
+ () =>
+ new Promise((resolve, reject) => {
+ deferreds.push({ resolve, reject });
+ }),
+ );
+ renderPanel(onUpdate);
+
+ fireEvent.change(screen.getByTitle("session approval policy"), {
+ target: { value: "yolo" },
+ });
+ await waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(1));
+
+ // A different field applies immediately despite the pending approval patch.
+ fireEvent.change(screen.getByTitle("thinking effort"), { target: { value: "low" } });
+ await waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(2));
+ expect(onUpdate).toHaveBeenLastCalledWith({
+ thinking: { enabled: true, effort: "low" },
+ });
+ });
+});
diff --git a/coding-agent/webui/app/src/components/SettingsPanel.tsx b/coding-agent/webui/app/src/components/SettingsPanel.tsx
new file mode 100644
index 000000000..de2e61d43
--- /dev/null
+++ b/coding-agent/webui/app/src/components/SettingsPanel.tsx
@@ -0,0 +1,196 @@
+import { useEffect, useRef, useState } from "react";
+import type { RuntimeConfigPatch } from "../lib/api";
+import type { ApprovalPolicy, ThinkingEffort } from "../lib/types";
+import { PROVIDERS } from "./Header";
+
+interface Props {
+ sessionId: string;
+ providerName: string | null;
+ modelName: string | null;
+ onUpdate: (patch: RuntimeConfigPatch) => Promise;
+}
+
+type Feedback = { kind: "saved" | "error"; text: string } | null;
+
+const inputCls =
+ "w-full rounded-lg border border-border bg-surface-0 px-3 py-1.5 text-sm text-fg focus:border-accent focus:outline-none";
+
+// Per-session runtime settings (PATCH /sessions/{id}/runtime-config). These
+// are distinct from the header's new-session defaults.
+export default function SettingsPanel({
+ sessionId,
+ providerName,
+ modelName,
+ onUpdate,
+}: Props) {
+ const [provider, setProvider] = useState(providerName ?? "");
+ const [model, setModel] = useState(modelName ?? "");
+ // The server does not report the session's current thinking config, so
+ // these start at the schema defaults and afterwards track what the user
+ // last applied (see the caption in the thinking section below).
+ const [thinkingEnabled, setThinkingEnabled] = useState(true);
+ const [thinkingEffort, setThinkingEffort] = useState("medium");
+ const [feedback, setFeedback] = useState(null);
+ const feedbackTimer = useRef | null>(null);
+
+ useEffect(() => {
+ return () => {
+ if (feedbackTimer.current) clearTimeout(feedbackTimer.current);
+ };
+ }, []);
+
+ type ApplyField = "provider" | "model" | "thinking" | "approval";
+ // Per-field promise chains: rapid changes to the same control are applied
+ // in order, so a stale success can never overwrite a later failure's
+ // feedback (or reach the server out of order).
+ const applyChains = useRef>>>({});
+
+ const apply = (field: ApplyField, patch: RuntimeConfigPatch) => {
+ const next = (applyChains.current[field] ?? Promise.resolve())
+ .then(() => onUpdate(patch))
+ .then(() => showFeedback({ kind: "saved", text: "Saved" }))
+ .catch((e) =>
+ showFeedback({
+ kind: "error",
+ text: `save failed: ${e instanceof Error ? e.message : String(e)}`,
+ }),
+ );
+ applyChains.current[field] = next;
+ };
+
+ const showFeedback = (next: NonNullable) => {
+ setFeedback(next);
+ if (feedbackTimer.current) clearTimeout(feedbackTimer.current);
+ feedbackTimer.current = setTimeout(() => setFeedback(null), 2500);
+ };
+
+ // The server replaces the whole thinking object (ThinkingConfigSchema fills
+ // omitted keys with defaults), so always send both fields using the user's
+ // last-known enabled state.
+ const applyThinking = (enabled: boolean, effort: ThinkingEffort) => {
+ setThinkingEnabled(enabled);
+ setThinkingEffort(effort);
+ apply("thinking", { thinking: { enabled, effort } });
+ };
+
+ const providerOptions = [
+ ...new Set([provider, ...PROVIDERS].filter((p) => p.trim())),
+ ];
+
+ return (
+
+
+
Session settings
+ {feedback && (
+
+ {feedback.text}
+
+ )}
+
+
+
+ Runtime settings for the current session, applied immediately.
+ Defaults for new sessions live in the header.
+
+
+ Provider
+ {
+ setProvider(e.target.value);
+ if (e.target.value) apply("provider", { provider: e.target.value });
+ }}
+ >
+ {!provider && (
+
+ select provider
+
+ )}
+ {providerOptions.map((p) => (
+
+ {p}
+
+ ))}
+
+
+
+ Model
+ setModel(e.target.value)}
+ onBlur={() => {
+ const next = model.trim();
+ if (next && next !== (modelName ?? "")) apply("model", { model: next });
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") (e.target as HTMLInputElement).blur();
+ }}
+ />
+
+
+
+ applyThinking(e.target.checked, thinkingEffort)}
+ />
+ Thinking
+
+
+ Effort
+
+ applyThinking(thinkingEnabled, e.target.value as ThinkingEffort)
+ }
+ >
+ low
+ medium
+ high
+
+
+
+ Initial values are defaults, not read from this session — the server
+ does not report its current thinking config. Changing either control
+ saves both.
+
+
+
+ Approval policy
+ apply("approval", { approval: e.target.value as ApprovalPolicy })}
+ >
+
+ select policy to apply
+
+ auto
+ interactive
+ yolo
+
+
+
+ The current policy is not exposed by the server. Selecting a policy
+ applies it immediately and also saves it as the default for new sessions.
+
+
+
+ );
+}
diff --git a/coding-agent/webui/app/src/components/Timeline.test.tsx b/coding-agent/webui/app/src/components/Timeline.test.tsx
new file mode 100644
index 000000000..1d9efef91
--- /dev/null
+++ b/coding-agent/webui/app/src/components/Timeline.test.tsx
@@ -0,0 +1,255 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import Timeline from "./Timeline";
+import type { TimelineItem } from "../lib/timeline";
+
+type ToolItem = Extract;
+
+const tool = (
+ id: string,
+ toolName: string,
+ args: Record,
+ extra: Partial = {},
+): ToolItem => ({
+ id,
+ kind: "tool",
+ agentId: "",
+ callId: id,
+ toolName,
+ args,
+ ...extra,
+});
+
+const noop = vi.fn();
+
+beforeEach(() => {
+ Element.prototype.scrollIntoView = vi.fn();
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+});
+
+describe("tool-call cards", () => {
+ it("groups consecutive tool calls under a collapsible count header", () => {
+ const items: TimelineItem[] = [
+ { id: "u1", kind: "user", text: "go" },
+ tool("t1", "read_file", { path: "src/a.ts" }),
+ tool("t2", "write_file", { path: "src/b.ts" }),
+ tool("t3", "bash", { cmd: "npm test" }),
+ { id: "a1", kind: "assistant", agentId: "", text: "done" },
+ ];
+ render( );
+
+ const header = screen.getByRole("button", { name: /3 tool calls/ });
+ expect(header.getAttribute("aria-expanded")).toBe("true");
+ expect(screen.getByText("read_file")).toBeTruthy();
+ expect(screen.getByText("bash")).toBeTruthy();
+
+ fireEvent.click(header);
+ expect(header.getAttribute("aria-expanded")).toBe("false");
+ expect(screen.queryByText("read_file")).toBeNull();
+ expect(screen.queryByText("bash")).toBeNull();
+
+ fireEvent.click(header);
+ expect(screen.getByText("read_file")).toBeTruthy();
+ });
+
+ it("does not group tool calls separated by assistant text", () => {
+ const items: TimelineItem[] = [
+ tool("t1", "read_file", { path: "src/a.ts" }),
+ { id: "a1", kind: "assistant", agentId: "", text: "note" },
+ tool("t2", "bash", { cmd: "pwd" }),
+ ];
+ render( );
+
+ expect(screen.queryByText(/tool calls/)).toBeNull();
+ expect(screen.getByText("read_file")).toBeTruthy();
+ expect(screen.getByText("bash")).toBeTruthy();
+ });
+
+ it("keeps card output collapsed until expanded, with summary and duration", () => {
+ const items: TimelineItem[] = [
+ tool("t1", "bash", { cmd: "npm test" }, {
+ result: "ok",
+ startedAt: "2026-06-12T00:00:00Z",
+ finishedAt: "2026-06-12T00:00:02.500Z",
+ }),
+ ];
+ render( );
+
+ // Summary and duration on the collapsed header; output hidden.
+ expect(screen.getByText("npm test")).toBeTruthy();
+ expect(screen.getByText("2.5s")).toBeTruthy();
+ expect(screen.queryByText("ok")).toBeNull();
+
+ fireEvent.click(screen.getByRole("button", { name: /bash/ }));
+ expect(screen.getByText("ok")).toBeTruthy();
+ });
+
+ it("shows a running indicator while the result is pending", () => {
+ const items: TimelineItem[] = [tool("t1", "bash", { cmd: "sleep 5" })];
+ render( );
+
+ expect(screen.getByTitle("running")).toBeTruthy();
+ });
+
+ it("tags cards from non-root agents as subagent", () => {
+ const items: TimelineItem[] = [
+ tool("t1", "bash", { cmd: "pwd" }, { agentId: "worker-1" }),
+ ];
+ render( );
+
+ expect(screen.getByText("subagent")).toBeTruthy();
+ expect(screen.getByText("[worker-1]")).toBeTruthy();
+ });
+});
+
+describe("thinking visibility", () => {
+ const items: TimelineItem[] = [
+ { id: "th1", kind: "thinking", agentId: "", text: "reasoning…" },
+ { id: "a1", kind: "assistant", agentId: "", text: "answer" },
+ ];
+
+ it("shows thinking blocks by default", () => {
+ render( );
+ expect(screen.getByText("reasoning…")).toBeTruthy();
+ });
+
+ it("hides thinking blocks when showThinking is false", () => {
+ render( );
+ expect(screen.queryByText("reasoning…")).toBeNull();
+ expect(screen.getByText("answer")).toBeTruthy();
+ });
+});
+
+describe("approval card", () => {
+ const approval = (extra: Partial> = {}) =>
+ ({
+ id: "ap1",
+ kind: "approval",
+ agentId: "",
+ requestId: "req-1",
+ toolName: "bash",
+ args: { cmd: "rm -rf build" },
+ ...extra,
+ }) as TimelineItem;
+
+ it("sends scope=session for always allow", () => {
+ const onApprove = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Always allow (this session)" }));
+ expect(onApprove).toHaveBeenCalledWith("req-1", true, "", "session");
+ });
+
+ it("sends scope=once for approve and deny", () => {
+ const onApprove = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Approve" }));
+ expect(onApprove).toHaveBeenCalledWith("req-1", true, "", "once");
+ fireEvent.click(screen.getByRole("button", { name: "Deny" }));
+ expect(onApprove).toHaveBeenCalledWith("req-1", false, "", "once");
+ });
+
+ it("counts down and shows a timed-out state on expiry", () => {
+ vi.useFakeTimers();
+ render(
+ ,
+ );
+
+ expect(screen.getByText("0:02")).toBeTruthy();
+ act(() => vi.advanceTimersByTime(1000));
+ expect(screen.getByText("0:01")).toBeTruthy();
+ act(() => vi.advanceTimersByTime(1000));
+
+ expect(screen.getByText(/timed out/)).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "Approve" })).toBeNull();
+ });
+
+ it("shows the timed-out state immediately for a replayed prompt past its deadline", () => {
+ // Prompt created long ago: the server already auto-denied on timeout, so
+ // the countdown must not restart from the full value on replay.
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/timed out/)).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "Approve" })).toBeNull();
+ });
+
+ it("counts down from promptedAt, not from mount", () => {
+ // Prompt created 10s ago with a 30s timeout: reopening the session shows
+ // ~20s remaining, not the full 30s.
+ render(
+ ,
+ );
+
+ expect(screen.getByText("0:20")).toBeTruthy();
+ });
+
+ it("reflects wall-clock jumps, not just timer ticks", () => {
+ // Background-tab throttling: the interval may lag, but the remaining time
+ // is recomputed from Date.now() against the stored deadline.
+ vi.useFakeTimers();
+ render(
+ ,
+ );
+
+ expect(screen.getByText("1:30")).toBeTruthy();
+ // Jump the wall clock 60s ahead, then let one (late) tick fire.
+ act(() => {
+ vi.setSystemTime(Date.now() + 60_000);
+ vi.advanceTimersByTime(1000);
+ });
+ expect(screen.getByText("0:29")).toBeTruthy();
+ });
+});
diff --git a/coding-agent/webui/app/src/components/Timeline.tsx b/coding-agent/webui/app/src/components/Timeline.tsx
index 407fae0e6..47d662577 100644
--- a/coding-agent/webui/app/src/components/Timeline.tsx
+++ b/coding-agent/webui/app/src/components/Timeline.tsx
@@ -1,24 +1,70 @@
import { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
-import type { TimelineItem } from "../lib/timeline";
+import { toolArgSummary, toolDuration, type TimelineItem } from "../lib/timeline";
+import type { ApprovalScope } from "../lib/types";
interface Props {
items: TimelineItem[];
- onApprove: (requestId: string, approved: boolean, feedback: string) => void;
+ onApprove: (
+ requestId: string,
+ approved: boolean,
+ feedback: string,
+ scope: ApprovalScope,
+ ) => void;
+ showThinking: boolean;
}
-export default function Timeline({ items, onApprove }: Props) {
+type ToolItem = Extract;
+type TimelineNode =
+ | TimelineItem
+ | { id: string; kind: "toolGroup"; tools: ToolItem[] };
+
+// Fold runs of consecutive tool items (>= 2) into a single group node; lone
+// tool calls stay standalone.
+function groupToolItems(items: TimelineItem[]): TimelineNode[] {
+ const nodes: TimelineNode[] = [];
+ let run: ToolItem[] = [];
+ const flush = () => {
+ if (run.length >= 2) {
+ nodes.push({ id: `g-${run[0].id}`, kind: "toolGroup", tools: run });
+ } else {
+ nodes.push(...run);
+ }
+ run = [];
+ };
+ for (const it of items) {
+ if (it.kind === "tool") {
+ run.push(it);
+ } else {
+ flush();
+ nodes.push(it);
+ }
+ }
+ flush();
+ return nodes;
+}
+
+export default function Timeline({ items, onApprove, showThinking }: Props) {
const endRef = useRef(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "auto" });
}, [items]);
+ const visible = showThinking
+ ? items
+ : items.filter((it) => it.kind !== "thinking");
+ const nodes = groupToolItems(visible);
+
return (
- {items.map((it) => (
-
- ))}
+ {nodes.map((node) =>
+ node.kind === "toolGroup" ? (
+
+ ) : (
+
+ ),
+ )}
@@ -26,9 +72,15 @@ export default function Timeline({ items, onApprove }: Props) {
}
function Agent({ id }: { id: string }) {
- return id ? (
- [{id}]
- ) : null;
+ if (!id) return null;
+ return (
+ <>
+ [{id}]
+
+ subagent
+
+ >
+ );
}
function Item({
@@ -70,26 +122,7 @@ function Item({
);
case "tool":
- return (
-
-
-
-
- ⚡ {item.toolName}
-
-
-
- {JSON.stringify(item.args, null, 2)}
-
- {item.result !== undefined && (
-
- {item.result}
-
- )}
-
- );
+ return
;
case "approval":
return
;
case "turnEnd":
@@ -113,6 +146,97 @@ function Item({
}
}
+function ToolGroup({ tools }: { tools: ToolItem[] }) {
+ const [open, setOpen] = useState(true);
+ const running = tools.filter((t) => t.result === undefined).length;
+ return (
+
+
setOpen((v) => !v)}
+ >
+ {open ? "▾" : "▸"}
+
+ {tools.length} tool call{tools.length === 1 ? "" : "s"}
+
+ {running > 0 && (
+ · {running} running
+ )}
+
+ {open && (
+
+ {tools.map((t) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function ToolCard({ item }: { item: ToolItem }) {
+ const [expanded, setExpanded] = useState(false);
+ const running = item.result === undefined;
+ const summary = toolArgSummary(item.toolName, item.args);
+ const duration = toolDuration(item);
+ return (
+
+
setExpanded((v) => !v)}
+ >
+ {running ? (
+
+ ) : item.isError ? (
+
+ ✗
+
+ ) : (
+
+ ✓
+
+ )}
+
+
+ {item.toolName}
+
+ {summary && (
+
+ {summary}
+
+ )}
+ {duration && (
+ {duration}
+ )}
+
+ {expanded ? "▾" : "▸"}
+
+
+ {expanded && (
+
+
+ {JSON.stringify(item.args, null, 2)}
+
+ {item.result !== undefined && (
+
+ {item.result}
+
+ )}
+
+ )}
+
+ );
+}
+
function MarkdownText({ text }: { text: string }) {
return (
@@ -121,6 +245,12 @@ function MarkdownText({ text }: { text: string }) {
);
}
+function formatClock(totalSeconds: number): string {
+ const m = Math.floor(totalSeconds / 60);
+ const s = totalSeconds % 60;
+ return `${m}:${String(s).padStart(2, "0")}`;
+}
+
function ApprovalCard({
item,
onApprove,
@@ -129,13 +259,49 @@ function ApprovalCard({
onApprove: Props["onApprove"];
}) {
const [feedback, setFeedback] = useState("");
- const done = !!item.resolved;
+ // Deadline (ms epoch) mirroring the server-side auto-deny: the server counts
+ // from the prompt's created_at, not from when this card mounts, so a
+ // replayed or reopened prompt does not restart the countdown. Items without
+ // a promptedAt (defensive fallback) count from mount.
+ const [deadline] = useState
(() => {
+ if (item.timeoutSeconds === undefined) return null;
+ const start = item.promptedAt ? new Date(item.promptedAt).getTime() : NaN;
+ const base = Number.isFinite(start) ? start : Date.now();
+ return base + item.timeoutSeconds * 1000;
+ });
+ const computeRemaining = () =>
+ deadline === null
+ ? null
+ : Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
+ const [remaining, setRemaining] = useState(computeRemaining);
+ const timedOut = remaining !== null && remaining <= 0;
+ const done = !!item.resolved || timedOut;
+
+ // Recompute from the wall clock each tick instead of decrementing, so
+ // background-tab timer throttling cannot lag the countdown behind the
+ // server deadline.
+ useEffect(() => {
+ if (done || deadline === null) return;
+ const tick = () => setRemaining(computeRemaining());
+ const timer = setInterval(tick, 1000);
+ document.addEventListener("visibilitychange", tick);
+ return () => {
+ clearInterval(timer);
+ document.removeEventListener("visibilitychange", tick);
+ };
+ }, [done, deadline]);
+
return (
-
+
Approval Required
+ {!done && remaining !== null && (
+
+ {formatClock(remaining)}
+
+ )}
@@ -144,23 +310,32 @@ function ApprovalCard({
{JSON.stringify(item.args, null, 2)}
{done ? (
-
→ {item.resolved}
+
+ → {timedOut && !item.resolved ? "timed out (auto-denied)" : item.resolved}
+
) : (
-
+
onApprove(item.requestId, true, feedback)}
+ onClick={() => onApprove(item.requestId, true, feedback, "once")}
>
Approve
+
onApprove(item.requestId, true, feedback, "session")}
+ >
+ Always allow (this session)
+
onApprove(item.requestId, false, feedback)}
+ onClick={() => onApprove(item.requestId, false, feedback, "once")}
>
Deny
setFeedback(e.target.value)}
diff --git a/coding-agent/webui/app/src/index.css b/coding-agent/webui/app/src/index.css
index f242ffac1..e1d469655 100644
--- a/coding-agent/webui/app/src/index.css
+++ b/coding-agent/webui/app/src/index.css
@@ -1,30 +1,31 @@
@import "tailwindcss";
@theme {
- --color-surface-0: #0a0c10;
- --color-surface-1: #12141a;
- --color-surface-2: #1a1d26;
- --color-border: #2a2e3a;
- --color-border-active: #3d4355;
- --color-fg: #e4e6ea;
- --color-muted: #7c829a;
+ --color-surface-0: #0f1011;
+ --color-surface-1: #131417;
+ --color-surface-2: #16181d;
+ --color-border: #26282e;
+ --color-border-active: #3a3d47;
+ --color-fg: #e3e5e9;
+ --color-muted: #7d8189;
--color-accent: #5b8def;
--color-accent-hover: #6e9cf5;
--color-ok: #3fb950;
--color-err: #f85149;
--color-warn: #d29922;
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}
/* Light theme overrides the same custom properties; applied when
is set (default is dark). */
:root[data-theme="light"] {
- --color-surface-0: #f5f6f8;
+ --color-surface-0: #f6f7f8;
--color-surface-1: #ffffff;
- --color-surface-2: #e9ecf1;
- --color-border: #d3d7de;
- --color-border-active: #9aa3b2;
- --color-fg: #1c1f27;
- --color-muted: #5f6678;
+ --color-surface-2: #eef0f2;
+ --color-border: #e0e2e6;
+ --color-border-active: #b9bec7;
+ --color-fg: #1d2026;
+ --color-muted: #62676f;
--color-accent: #2f6bd8;
--color-accent-hover: #255cb8;
--color-ok: #1a7f37;
diff --git a/coding-agent/webui/app/src/lib/api.ts b/coding-agent/webui/app/src/lib/api.ts
index 730827eb7..a5f89c80a 100644
--- a/coding-agent/webui/app/src/lib/api.ts
+++ b/coding-agent/webui/app/src/lib/api.ts
@@ -2,16 +2,36 @@ import { parseSSE } from "./sse";
import type {
ApprovalPolicy,
ApprovalScope,
+ CheckpointMetadata,
DisplayStreamEvent,
MemoryReviewRecord,
+ MemoryReviewTransitionStatus,
ProviderModels,
RuntimeRun,
SessionSummary,
SessionResult,
+ ThinkingConfig,
WorkspaceDiff,
WorkspacePatch,
} from "./types";
+// PATCH /sessions/{id}/runtime-config — omitted fields stay unchanged.
+// Mirrors RuntimeConfigUpdateRequest in src/coding_agent/server/schemas.py.
+export interface RuntimeConfigPatch {
+ model?: string;
+ provider?: string;
+ baseUrl?: string;
+ approval?: ApprovalPolicy;
+ thinking?: ThinkingConfig;
+}
+
+export interface RuntimeConfigUpdateResponse {
+ session_id: string;
+ provider_name: string | null;
+ model_name: string | null;
+ base_url: string | null;
+}
+
export interface ClientConfig {
baseUrl: string;
apiKey?: string;
@@ -62,17 +82,23 @@ export class AgentClient {
async updateRuntimeConfig(
sessionId: string,
- opts: { approval?: ApprovalPolicy },
- ): Promise
{
+ opts: RuntimeConfigPatch,
+ ): Promise {
const body: Record = {};
+ if (opts.model !== undefined) body.model = opts.model;
+ if (opts.provider !== undefined) body.provider = opts.provider;
+ if (opts.baseUrl !== undefined) body.base_url = opts.baseUrl;
if (opts.approval !== undefined) body.approval = opts.approval;
- await this.check(
+ if (opts.thinking !== undefined) body.thinking = opts.thinking;
+ return (
+ await this.check(
await fetch(this.url(`/sessions/${sessionId}/runtime-config`), {
- method: "POST",
+ method: "PATCH",
headers: this.headers(true),
body: JSON.stringify(body),
}),
- );
+ )
+ ).json();
}
async listSessions(): Promise {
@@ -220,6 +246,63 @@ export class AgentClient {
return Array.isArray(data) ? (data as MemoryReviewRecord[]) : [];
}
+ // POST /sessions/{id}/memory/reviews/{candidate_id} — accept/reject/archive.
+ // reason must be null or non-empty (schema: min_length 1).
+ async transitionMemoryReview(
+ sessionId: string,
+ candidateId: string,
+ status: MemoryReviewTransitionStatus,
+ reason?: string,
+ ): Promise {
+ const trimmed = reason?.trim();
+ await this.check(
+ await fetch(
+ this.url(`/sessions/${sessionId}/memory/reviews/${encodeURIComponent(candidateId)}`),
+ {
+ method: "POST",
+ headers: this.headers(true),
+ body: JSON.stringify({ status, reason: trimmed ? trimmed : null }),
+ },
+ ),
+ );
+ }
+
+ async listCheckpoints(sessionId: string): Promise {
+ const data = await (
+ await this.check(
+ await fetch(this.url(`/sessions/${sessionId}/checkpoints`), {
+ headers: this.headers(),
+ }),
+ )
+ ).json() as { checkpoints: CheckpointMetadata[] };
+ return Array.isArray(data.checkpoints) ? data.checkpoints : [];
+ }
+
+ async captureCheckpoint(sessionId: string, label?: string): Promise {
+ const trimmed = label?.trim();
+ return (
+ await this.check(
+ await fetch(this.url(`/sessions/${sessionId}/checkpoints`), {
+ method: "POST",
+ headers: this.headers(true),
+ body: JSON.stringify(trimmed ? { label: trimmed } : {}),
+ }),
+ )
+ ).json();
+ }
+
+ // Restore rewinds session history/runtime config; workspace files are unchanged.
+ async restoreCheckpoint(sessionId: string, checkpointId: string): Promise {
+ await this.check(
+ await fetch(
+ this.url(
+ `/sessions/${sessionId}/checkpoints/${encodeURIComponent(checkpointId)}/restore`,
+ ),
+ { method: "POST", headers: this.headers() },
+ ),
+ );
+ }
+
// GET /providers/{provider}/models. Unknown providers return 422 (throws);
// provider-side failures return 200 with source="unavailable" and models=[].
async listProviderModels(provider: string, signal?: AbortSignal): Promise {
diff --git a/coding-agent/webui/app/src/lib/timeline.test.ts b/coding-agent/webui/app/src/lib/timeline.test.ts
index 25b04fe11..48435bc24 100644
--- a/coding-agent/webui/app/src/lib/timeline.test.ts
+++ b/coding-agent/webui/app/src/lib/timeline.test.ts
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import type { DisplayStreamEvent } from "./types";
-import { applyEvent, replayEvents, type TimelineItem } from "./timeline";
+import {
+ applyEvent,
+ replayEvents,
+ toolArgSummary,
+ toolDuration,
+ type TimelineItem,
+} from "./timeline";
const withoutIds = (items: TimelineItem[]) =>
items.map(({ id: _id, ...item }) => item);
@@ -9,6 +15,7 @@ const displayEvent = (
event: DisplayStreamEvent["event"],
payload: Record,
sequence: number,
+ createdAt = "2026-06-12T00:00:00Z",
): DisplayStreamEvent => ({
event,
data: {
@@ -17,7 +24,7 @@ const displayEvent = (
sequence,
display_kind: event,
payload,
- created_at: "2026-06-12T00:00:00Z",
+ created_at: createdAt,
},
} as DisplayStreamEvent);
@@ -27,7 +34,7 @@ describe("replayEvents", () => {
displayEvent("assistant_text_delta", { agent_id: "", content: "hello " }, 1),
displayEvent("assistant_text_delta", { agent_id: "", content: "world" }, 2),
displayEvent("tool_call", { agent_id: "", call_id: "call-1", tool_name: "bash", arguments: { cmd: "pwd" } }, 3),
- displayEvent("tool_result", { agent_id: "", call_id: "call-1", tool_name: "bash", display_result: "/repo", is_error: false }, 4),
+ displayEvent("tool_result", { agent_id: "", call_id: "call-1", tool_name: "bash", display_result: "/repo", is_error: false }, 4, "2026-06-12T00:00:02.500Z"),
displayEvent("final_result", { agent_id: "", turn_id: "turn-1", completion_status: "completed" }, 5),
];
@@ -35,6 +42,15 @@ describe("replayEvents", () => {
const streamed = events.reduce(applyEvent, seed);
expect(withoutIds(replayEvents(seed, events))).toEqual(withoutIds(streamed));
+
+ const toolItem = replayEvents(seed, events).find((it) => it.kind === "tool");
+ expect(toolItem).toMatchObject({
+ kind: "tool",
+ callId: "call-1",
+ toolName: "bash",
+ startedAt: "2026-06-12T00:00:00Z",
+ finishedAt: "2026-06-12T00:00:02.500Z",
+ });
});
it("marks approval prompts resolved when replaying approval results", () => {
@@ -45,6 +61,7 @@ describe("replayEvents", () => {
agent_id: "",
request_id: "approval-1",
tool_call: { call_id: "call-1", tool_name: "bash", arguments: { cmd: "pwd" } },
+ timeout_seconds: 30,
},
1,
),
@@ -58,6 +75,8 @@ describe("replayEvents", () => {
requestId: "approval-1",
toolName: "bash",
args: { cmd: "pwd" },
+ timeoutSeconds: 30,
+ promptedAt: "2026-06-12T00:00:00Z",
resolved: "approved",
},
]);
@@ -84,7 +103,47 @@ describe("replayEvents", () => {
requestId: "approval-1",
toolName: "bash",
args: { cmd: "pwd" },
+ promptedAt: "2026-06-12T00:00:00Z",
},
]);
});
});
+
+
+describe("toolArgSummary", () => {
+ it("prefers path-like and command-like arguments", () => {
+ expect(toolArgSummary("read_file", { path: "src/a.ts", limit: 10 })).toBe("src/a.ts");
+ expect(toolArgSummary("bash", { cmd: "npm test" })).toBe("npm test");
+ });
+
+ it("falls back to the first scalar argument and collapses whitespace", () => {
+ expect(toolArgSummary("grep", { pattern: "foo bar\nbaz" })).toBe("foo bar baz");
+ expect(toolArgSummary("custom", { limit: 42 })).toBe("42");
+ });
+
+ it("truncates long values to one line", () => {
+ const summary = toolArgSummary("bash", { cmd: "x".repeat(200) });
+ expect(summary.length).toBe(80);
+ expect(summary.endsWith("…")).toBe(true);
+ });
+
+ it("returns empty string when no scalar argument exists", () => {
+ expect(toolArgSummary("noop", { nested: { a: 1 } })).toBe("");
+ });
+});
+
+describe("toolDuration", () => {
+ it("derives a duration from call/result timestamps", () => {
+ expect(
+ toolDuration({
+ startedAt: "2026-06-12T00:00:00Z",
+ finishedAt: "2026-06-12T00:00:02.500Z",
+ }),
+ ).toBe("2.5s");
+ });
+
+ it("returns null when timestamps are missing", () => {
+ expect(toolDuration({ startedAt: "2026-06-12T00:00:00Z" })).toBeNull();
+ expect(toolDuration({})).toBeNull();
+ });
+});
diff --git a/coding-agent/webui/app/src/lib/timeline.ts b/coding-agent/webui/app/src/lib/timeline.ts
index aa1b526ee..616ccbfde 100644
--- a/coding-agent/webui/app/src/lib/timeline.ts
+++ b/coding-agent/webui/app/src/lib/timeline.ts
@@ -24,6 +24,8 @@ export type TimelineItem =
args: Record;
result?: string;
isError?: boolean;
+ startedAt?: string;
+ finishedAt?: string;
}
| {
id: string;
@@ -32,6 +34,8 @@ export type TimelineItem =
requestId: string;
toolName: string;
args: Record;
+ timeoutSeconds?: number;
+ promptedAt?: string;
resolved?: "approved" | "denied";
}
| { id: string; kind: "turnEnd"; status: string }
@@ -112,6 +116,7 @@ export function applyEvent(
callId: d.call_id ?? envelope.source_event_id,
toolName: d.tool_name ?? "",
args: d.arguments ?? {},
+ startedAt: envelope.created_at,
},
];
}
@@ -120,7 +125,7 @@ export function applyEvent(
const d = envelope.payload;
return items.map((it) =>
it.kind === "tool" && it.callId === d.call_id
- ? { ...it, result: resultText(d), isError: d.is_error }
+ ? { ...it, result: resultText(d), isError: d.is_error, finishedAt: envelope.created_at }
: it,
);
}
@@ -136,6 +141,8 @@ export function applyEvent(
requestId: d.request_id ?? envelope.source_event_id,
toolName: d.tool_call?.tool_name ?? "",
args: d.tool_call?.arguments ?? {},
+ timeoutSeconds: d.timeout_seconds,
+ promptedAt: envelope.created_at,
},
];
}
@@ -184,3 +191,64 @@ export function isRootTurnEnd(ev: DisplayStreamEvent): boolean {
!envelope.payload.agent_id
);
}
+
+const SUMMARY_KEYS = [
+ "path",
+ "file_path",
+ "filePath",
+ "filename",
+ "cmd",
+ "command",
+ "pattern",
+ "query",
+ "url",
+ "goal",
+ "prompt",
+];
+
+// One-line argument summary for tool cards: prefer well-known path/command
+// keys, fall back to the first scalar argument, truncated to one line.
+export function toolArgSummary(
+ _toolName: string,
+ args: Record,
+): string {
+ let raw: string | null = null;
+ for (const key of SUMMARY_KEYS) {
+ const v = args[key];
+ if (typeof v === "string" && v.trim()) {
+ raw = v;
+ break;
+ }
+ }
+ if (raw === null) {
+ for (const v of Object.values(args)) {
+ if (typeof v === "string" && v.trim()) {
+ raw = v;
+ break;
+ }
+ if (typeof v === "number" || typeof v === "boolean") {
+ raw = String(v);
+ break;
+ }
+ }
+ }
+ if (raw === null) return "";
+ const oneLine = raw.replace(/\s+/g, " ").trim();
+ return oneLine.length > 80 ? `${oneLine.slice(0, 79)}…` : oneLine;
+}
+
+// Duration between tool_call and tool_result timestamps, e.g. "1.2s".
+// Returns null when either timestamp is missing or unparseable.
+export function toolDuration(item: {
+ startedAt?: string;
+ finishedAt?: string;
+}): string | null {
+ if (!item.startedAt || !item.finishedAt) return null;
+ const ms = new Date(item.finishedAt).getTime() - new Date(item.startedAt).getTime();
+ if (!Number.isFinite(ms) || ms < 0) return null;
+ if (ms < 1000) return `${Math.round(ms)}ms`;
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
+ const m = Math.floor(ms / 60_000);
+ const s = Math.round((ms % 60_000) / 1000);
+ return `${m}m${s}s`;
+}
diff --git a/coding-agent/webui/app/src/lib/types.ts b/coding-agent/webui/app/src/lib/types.ts
index 6afc008e7..5528090ee 100644
--- a/coding-agent/webui/app/src/lib/types.ts
+++ b/coding-agent/webui/app/src/lib/types.ts
@@ -121,6 +121,13 @@ export type DisplayStreamEvent =
export type ApprovalPolicy = "auto" | "interactive" | "yolo";
export type ApprovalScope = "once" | "session";
+// Mirrors ThinkingConfigSchema in src/coding_agent/server/schemas.py.
+export type ThinkingEffort = "low" | "medium" | "high";
+export interface ThinkingConfig {
+ enabled: boolean;
+ effort: ThinkingEffort;
+}
+
export interface SessionSummary {
session_id: string;
id: string;
@@ -203,11 +210,28 @@ export interface ContextPack {
export interface SessionResult {
session_id: string;
status: string;
+ turn_status?: string;
+ turn_id?: string | null;
+ provider_name?: string | null;
+ model_name?: string | null;
final_answer: string | null;
verification_summary: string | null;
failure_details: string | null;
}
+// POST /sessions/{id}/memory/reviews/{candidate_id} — target statuses.
+export type MemoryReviewTransitionStatus = "accepted" | "rejected" | "archived";
+
+export interface CheckpointMetadata {
+ checkpoint_id: string;
+ tape_id: string;
+ session_id: string | null;
+ entry_count: number;
+ window_start: number;
+ created_at: string;
+ label: string | null;
+}
+
export interface DiffFile {
path: string;
status: "added" | "modified" | "deleted" | "renamed" | "binary" | "unknown";