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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,050 changes: 1,049 additions & 1 deletion coding-agent/webui/app/src/App.test.tsx

Large diffs are not rendered by default.

321 changes: 263 additions & 58 deletions coding-agent/webui/app/src/App.tsx

Large diffs are not rendered by default.

145 changes: 145 additions & 0 deletions coding-agent/webui/app/src/components/CheckpointsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<string, unknown>) => 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<string, unknown>);
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(<CheckpointsPanel client={client} sessionId="s1" onRestored={() => 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<Record<string, unknown>> = [];
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(
<CheckpointsPanel
client={client}
sessionId="s1"
onRestored={() => 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(<CheckpointsPanel client={client} sessionId="s1" onRestored={onRestored} />);

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(<CheckpointsPanel client={client} sessionId="s1" onRestored={() => 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();
});
});
173 changes: 173 additions & 0 deletions coding-agent/webui/app/src/components/CheckpointsPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
// 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<CheckpointMetadata[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [label, setLabel] = useState("");
const [creating, setCreating] = useState(false);
const [confirmingId, setConfirmingId] = useState<string | null>(null);
const [restoringId, setRestoringId] = useState<string | null>(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 (
<section className="flex h-full min-h-0 flex-col bg-surface-1">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="text-sm font-semibold text-fg">
Checkpoints · {checkpoints.length}
</div>
{error && <div className="text-xs text-err">{error}</div>}
</div>
<div className="flex items-center gap-2 border-b border-border px-4 py-2">
<input
className="min-w-0 flex-1 rounded-md border border-border bg-surface-0 px-2 py-1 text-xs text-fg placeholder:text-muted focus:border-accent focus:outline-none"
placeholder="label (optional)"
aria-label="checkpoint label"
value={label}
onChange={(e) => setLabel(e.target.value)}
/>
<button
className="shrink-0 rounded-md border border-border px-2 py-1 text-xs text-fg transition-colors hover:border-border-active disabled:opacity-40"
disabled={creating}
onClick={() => void create()}
>
{creating ? "Creating…" : "Create checkpoint"}
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{loading ? (
<div className="px-4 py-3 text-sm text-muted">Loading checkpoints…</div>
) : checkpoints.length === 0 ? (
<div className="px-4 py-3 text-sm text-muted">No checkpoints</div>
) : (
checkpoints.map((checkpoint) => (
<div key={checkpoint.checkpoint_id} className="border-b border-border px-4 py-2 text-xs">
<div className="flex items-center gap-2">
<span
className="min-w-0 flex-1 truncate font-mono text-fg"
title={checkpoint.checkpoint_id}
>
{checkpoint.label?.trim() || checkpoint.checkpoint_id}
</span>
<span className="shrink-0 text-muted">{formatTime(checkpoint.created_at)}</span>
{confirmingId !== checkpoint.checkpoint_id && (
<button
className="shrink-0 rounded-md border border-border px-2 py-0.5 text-[11px] text-fg transition-colors hover:border-border-active disabled:opacity-40"
disabled={restoringId !== null}
onClick={() => setConfirmingId(checkpoint.checkpoint_id)}
>
Restore
</button>
)}
</div>
<div className="mt-0.5 truncate font-mono text-muted">
{checkpoint.checkpoint_id} · {checkpoint.entry_count}{" "}
{checkpoint.entry_count === 1 ? "entry" : "entries"}
</div>
{confirmingId === checkpoint.checkpoint_id && (
<div className="mt-2 rounded-md border border-warn/40 bg-warn/5 px-2 py-1.5">
<div className="text-[11px] text-warn">
Restore rewinds session history and runtime settings to this checkpoint.
Workspace files are not restored; inspect or save the workspace diff first.
</div>
<div className="mt-1.5 flex items-center gap-2">
<button
className="rounded-md border border-warn/40 px-2 py-0.5 text-[11px] font-medium text-warn transition-colors hover:bg-warn/10 disabled:opacity-40"
disabled={restoringId !== null}
onClick={() => void restore(checkpoint.checkpoint_id)}
>
{restoringId === checkpoint.checkpoint_id
? "Restoring…"
: "Confirm restore"}
</button>
<button
className="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted transition-colors hover:text-fg disabled:opacity-40"
disabled={restoringId !== null}
onClick={() => setConfirmingId(null)}
>
Cancel
</button>
</div>
</div>
)}
</div>
))
)}
</div>
</section>
);
}

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",
});
}
8 changes: 4 additions & 4 deletions coding-agent/webui/app/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ export default function Composer({
streaming,
}: Props) {
return (
<footer className="flex items-end gap-3 border-t border-border bg-surface-1 px-4 py-3">
<footer className="flex min-w-0 items-end gap-2 border-t border-border bg-surface-1 px-2 py-3 sm:gap-3 sm:px-4">
<textarea
className="min-h-[48px] flex-1 resize-none rounded-xl border border-border bg-surface-0 px-4 py-2.5 text-sm text-fg placeholder:text-muted focus:border-accent focus:outline-none"
className="min-h-[48px] min-w-0 flex-1 resize-none rounded-xl border border-border bg-surface-0 px-3 py-2.5 text-sm text-fg placeholder:text-muted focus:border-accent focus:outline-none sm:px-4"
placeholder="Ask the agent… (Enter to send, Shift+Enter for newline)"
value={prompt}
onChange={(e) => onPromptChange(e.target.value)}
Expand All @@ -30,14 +30,14 @@ export default function Composer({
}}
/>
<button
className="rounded-xl bg-accent px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-hover disabled:opacity-40"
className="rounded-xl bg-accent px-3 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-hover disabled:opacity-40 sm:px-5"
disabled={disabled}
onClick={onSend}
>
Send
</button>
<button
className="rounded-xl border border-border px-5 py-2.5 text-sm text-fg transition-colors hover:border-border-active disabled:opacity-40"
className="rounded-xl border border-border px-3 py-2.5 text-sm text-fg transition-colors hover:border-border-active disabled:opacity-40 sm:px-5"
disabled={!streaming}
onClick={onCancel}
>
Expand Down
10 changes: 5 additions & 5 deletions coding-agent/webui/app/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface Props {

export default function DiffPanel({ diff, patch, onClose }: Props) {
return (
<section className="border-t border-border bg-surface-1">
<section className="flex h-full min-h-0 flex-col bg-surface-1">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="text-sm font-semibold text-fg">
Workspace diff · +{diff.additions} -{diff.deletions}
Expand All @@ -20,8 +20,8 @@ export default function DiffPanel({ diff, patch, onClose }: Props) {
Close
</button>
</div>
<div className="grid max-h-80 overflow-hidden md:grid-cols-[18rem_minmax(0,1fr)]">
<div className="overflow-auto border-b border-border md:border-r md:border-b-0">
<div className="flex min-h-0 flex-1 flex-col">
<div className="max-h-[40%] overflow-auto border-b border-border">
{diff.files.length === 0 ? (
<div className="px-4 py-3 text-sm text-muted">No changes</div>
) : (
Expand All @@ -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"
>
<span className={statusColor(file.status)}>{file.status}</span>
<span className="truncate text-fg" title={file.path}>
<span className="truncate font-mono text-fg" title={file.path}>
{file.path}
</span>
<span className="text-muted">
Expand All @@ -44,7 +44,7 @@ export default function DiffPanel({ diff, patch, onClose }: Props) {
))
)}
</div>
<pre className="overflow-auto bg-surface-0 px-4 py-3 text-xs leading-relaxed">
<pre className="min-h-0 flex-1 overflow-auto bg-surface-0 px-4 py-3 text-xs leading-relaxed">
{patch?.patch
? patch.patch.split("\n").map((line, index) => (
<div key={`${index}:${line}`} className={lineColor(line)}>
Expand Down
Loading
Loading