web: professional UI overhaul — ink/slate design system, archive+active sections, skeletons, error boundary, conn banner, usage chip, message copy, spawn progress steps; 162 tests ≥95% coverage

This commit is contained in:
Raphael Westphal
2026-08-18 18:19:25 +02:00
parent 42fcaa1fe3
commit e626b9cc6e
12 changed files with 1583 additions and 552 deletions
+42 -2
View File
@@ -7,7 +7,7 @@ import {
} from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it } from "vitest";
import App from "./App";
import App, { ErrorBoundary } from "./App";
import { clearSettings } from "./settings";
import {
FakeWebSocket,
@@ -114,7 +114,7 @@ describe("App shell", () => {
const sock = FakeWebSocket.last();
act(() => sock.serverOpen());
expect(
screen.getByRole("img", { name: "connection open" }),
screen.getByTitle("ws open"),
).toBeInTheDocument();
const sidebar = screen.getByLabelText("Sessions");
@@ -223,3 +223,43 @@ describe("App shell", () => {
function container_backdrop(): HTMLElement | null {
return document.querySelector(".sidebar-backdrop");
}
describe("ErrorBoundary direct", () => {
it("catches child render error and offers reload", () => {
function Bomb(): React.ReactNode {
throw new Error("kaboom-ui");
}
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
function Harness(): React.ReactNode {
return (
<MemoryRouter>
<ErrorBoundary>
<Bomb />
</ErrorBoundary>
</MemoryRouter>
);
}
render(<Harness />);
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/kaboom-ui/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument();
consoleSpy.mockRestore();
});
});
describe("conn banner + sidebar sections", () => {
it("reconnecting banner shows before ws opens; Active/Archive sections render", async () => {
seedApi(); // fixture sessions: s1 online, s2/s3 offline
seedSettings();
renderApp("/");
// before the fake socket opens: banner visible
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
expect(screen.getByText(/reconnecting/i)).toBeInTheDocument();
expect(screen.getByText("Active")).toBeInTheDocument();
expect(screen.getByText("Archive")).toBeInTheDocument();
act(() => FakeWebSocket.last().serverOpen());
expect(screen.queryByText(/reconnecting/i)).toBeNull();
});
});
+95 -31
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { Component, useEffect, useState, type ReactNode } from "react";
import {
NavLink,
Navigate,
@@ -12,6 +12,74 @@ import SpawnView from "./SpawnView";
import SettingsGate from "./SettingsGate";
import { classNames, useSessions, useToasts } from "./store";
import { clearSettings, getSettings } from "./settings";
import type { SessionListItem } from "./protocol";
interface BoundaryState {
error: Error | null;
}
export class ErrorBoundary extends Component<{ children: ReactNode }, BoundaryState> {
state: BoundaryState = { error: null };
static getDerivedStateFromError(error: Error): BoundaryState {
return { error };
}
render(): ReactNode {
if (this.state.error === null) return this.props.children;
return (
<div className="err-boundary" role="alert">
<h2>Something broke</h2>
<pre>{String(this.state.error?.stack ?? this.state.error)}</pre>
<button
type="button"
className="btn-secondary"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
);
}
}
function SidebarSection({
label,
sessions,
emptyText,
}: {
label: string;
sessions: SessionListItem[];
emptyText: string;
}): ReactNode {
return (
<>
<div className="sidebar-section-label">{label}</div>
{sessions.length === 0 && (
<div className="sidebar-empty">{emptyText}</div>
)}
{sessions.map((s) => (
<NavLink
key={s.id}
to={`/s/${s.id}`}
className={({ isActive }) =>
classNames(
"session-link",
isActive && "active",
!s.online && "archived",
)
}
>
<span
className={classNames("online-dot", s.online && "on")}
style={{ display: "inline-block" }}
/>
<span className="link-label">{s.name ?? s.repo ?? s.id}</span>
</NavLink>
))}
</>
);
}
export default function App() {
const [configured, setConfigured] = useState<boolean>(getSettings() !== null);
@@ -28,6 +96,11 @@ export default function App() {
if (store === null)
return <SettingsGate onSaved={() => setConfigured(true)} />;
const byActivity = (a: SessionListItem, b: SessionListItem): number =>
(b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt);
const active = store.sessions.filter((s) => s.online).sort(byActivity);
const archived = store.sessions.filter((s) => !s.online).sort(byActivity);
return (
<div className="app-shell">
<nav
@@ -41,12 +114,10 @@ export default function App() {
</span>
lvmh
</div>
<span
className={classNames("conn-dot", store.state)}
title={`ws ${store.state}`}
role="img"
aria-label={`connection ${store.state}`}
/>
<span className="conn-chip" title={`ws ${store.state}`}>
<span className={classNames("conn-dot", store.state)} />
{store.state}
</span>
</div>
<NavLink
to="/new"
@@ -57,30 +128,16 @@ export default function App() {
+ Spawn session
</NavLink>
<div className="sidebar-sessions">
{[...store.sessions]
.filter((s) => s.online)
.sort(
(a, b) =>
(b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt),
)
.map((s) => (
<NavLink
key={s.id}
to={`/s/${s.id}`}
className={({ isActive }) =>
classNames("session-link", isActive && "active")
}
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
<span
className={classNames("online-dot", s.online && "on")}
style={{ display: "inline-block" }}
<SidebarSection
label="Active"
sessions={active}
emptyText="no active pi"
/>
<SidebarSection
label="Archive"
sessions={archived}
emptyText="nothing archived"
/>
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{s.name ?? s.repo ?? s.id}
</span>
</NavLink>
))}
</div>
<div className="sidebar-footer">
<span className="spacer" />
@@ -104,6 +161,12 @@ export default function App() {
/>
)}
<main className="main">
{store.state !== "open" && (
<div className="conn-banner" role="status">
<span className={classNames("conn-dot", store.state)} />
connection {store.state} reconnecting
</div>
)}
<button
type="button"
className="icon-btn menu-btn"
@@ -112,12 +175,12 @@ export default function App() {
display: "none",
padding: "8px 12px",
alignSelf: "flex-start",
borderRadius: 0,
}}
onClick={() => setSidebarOpen(true)}
>
</button>
<ErrorBoundary>
<Routes>
<Route
path="/"
@@ -139,6 +202,7 @@ export default function App() {
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</ErrorBoundary>
</main>
<div className="toasts" role="status">
{toasts.map((t) => (
+35 -1
View File
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import type { ChatMessage, ToolState } from "./derive";
@@ -300,3 +300,37 @@ describe("ChatStream", () => {
expect(scroller.scrollTop).toBe(1000);
});
});
describe("copy button", () => {
it("copy button writes message text and flashes copied", async () => {
const writeText = vi.fn(() => Promise.resolve());
Object.assign(navigator, { clipboard: { writeText } });
render(
<Bubble
msg={{ key: "k", role: "assistant", text: "copy me", thinking: null, toolCalls: [], toolCallId: null, streaming: false }}
tools={new Map()}
/>,
);
const btn = screen.getByRole("button", { name: "Copy message" });
fireEvent.click(btn);
expect(writeText).toHaveBeenCalledWith("copy me");
await waitFor(() => expect(screen.getByText("copied")).toBeInTheDocument());
});
it("user bubbles get a copy button, toolResults do not", () => {
const { rerender } = render(
<Bubble
msg={{ key: "u", role: "user", text: "hi", thinking: null, toolCalls: [], toolCallId: null, streaming: false }}
tools={new Map()}
/>,
);
expect(screen.getByRole("button", { name: "Copy message" })).toBeInTheDocument();
rerender(
<Bubble
msg={{ key: "t", role: "toolResult", text: "r", thinking: null, toolCalls: [], toolCallId: "c1", streaming: false }}
tools={new Map()}
/>,
);
expect(screen.queryByRole("button", { name: "Copy message" })).toBeNull();
});
});
+51 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import type { ChatMessage, ToolState } from "./derive";
const PREVIEW_LEN: number = 120;
@@ -8,27 +8,54 @@ function oneLine(text: string): string {
return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}` : flat;
}
function CopyButton({ text }: { text: string }): React.ReactNode {
const [copied, setCopied] = useState<boolean>(false);
return (
<button
type="button"
className="msg-copy"
aria-label="Copy message"
onClick={() => {
void navigator.clipboard?.writeText(text).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
});
}}
>
{copied ? "copied" : "copy"}
</button>
);
}
function ToolCard({ tool }: { tool: ToolState }) {
const status: string = tool.running
? "running…"
: tool.isError
? "error"
: "done";
const statusClass: string = tool.isError ? "tool-status-err" : "tool-status-ok";
const statusClass: string = tool.isError
? "tool-status-err"
: "tool-status-ok";
return (
<details className="tool-card">
<summary>
<span className="tool-name">🛠 {tool.name}</span>
<span className={tool.running ? "" : statusClass}>{tool.running ? "working…" : status}</span>
<span className={tool.running ? "" : statusClass}>
{tool.running ? "working…" : status}
</span>
</summary>
<div className="tool-body">
<div>
<strong>args</strong>
<pre style={{ margin: "4px 0 10px", whiteSpace: "pre-wrap" }}>{tool.args}</pre>
<pre style={{ margin: "4px 0 10px", whiteSpace: "pre-wrap" }}>
{tool.args}
</pre>
</div>
<div>
<strong>result</strong>
<pre style={{ margin: "4px 0 0", whiteSpace: "pre-wrap" }}>{tool.preview}</pre>
<pre style={{ margin: "4px 0 0", whiteSpace: "pre-wrap" }}>
{tool.preview}
</pre>
</div>
</div>
</details>
@@ -44,8 +71,19 @@ function Thinking({ text }: { text: string }) {
);
}
export function Bubble({ msg, tools }: { msg: ChatMessage; tools: Map<string, ToolState> }) {
const rowClass = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "system";
export function Bubble({
msg,
tools,
}: {
msg: ChatMessage;
tools: Map<string, ToolState>;
}) {
const rowClass =
msg.role === "user"
? "user"
: msg.role === "assistant"
? "assistant"
: "system";
const msgTools: ToolState[] = [];
if (msg.role === "assistant") {
for (const c of msg.toolCalls) {
@@ -53,10 +91,15 @@ export function Bubble({ msg, tools }: { msg: ChatMessage; tools: Map<string, To
if (t !== undefined) msgTools.push(t);
}
}
const copyable: boolean =
msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0);
return (
<div className={`bubble-row ${rowClass}`}>
{copyable && <CopyButton text={msg.text} />}
<div className="bubble">
{msg.thinking !== null && msg.thinking.length > 0 && <Thinking text={msg.thinking} />}
{msg.thinking !== null && msg.thinking.length > 0 && (
<Thinking text={msg.thinking} />
)}
{msgTools.map((t) => (
<ToolCard key={t.id} tool={t} />
))}
+29
View File
@@ -497,3 +497,32 @@ describe("ChatView unknown session", () => {
expect(screen.queryByRole("button", { name: /Close pi/ })).toBeNull();
});
});
describe("ChatView usage chip", () => {
it("usage chip renders when agent_end carried usage", async () => {
mockFetchJson((url) => {
if (url.startsWith("http://srv/api/sessions/s1/events")) {
seq = 0;
return [
...historyEvents(),
ev("agent_end", { usage: { inputTokens: 1200, outputTokens: 340, totalCost: 0.02 } }),
];
}
return [];
});
renderChat(makeStore());
await screen.findByText("hello there");
expect(screen.getByText(/↑1,200 ↓340/)).toBeInTheDocument();
expect(screen.getByText(/\$0\.02/)).toBeInTheDocument();
});
it("usage chip hidden when no usage seen", async () => {
mockFetchJson((url) => {
if (url.startsWith("http://srv/api/sessions/s1/events")) return historyEvents();
return [];
});
renderChat(makeStore());
await screen.findByText("hello there");
expect(screen.queryByText(/↑\d/)).toBeNull();
});
});
+8
View File
@@ -167,6 +167,14 @@ export default function ChatView({ store, pushToast }: Props) {
{session?.name ?? session?.repo ?? sessionId}
</div>
<div className="sub">{session?.model}</div>
{(chat.usage.inputTokens > 0 || chat.usage.outputTokens > 0) && (
<span className="usage-chip" title="tokens this session">
{chat.usage.inputTokens.toLocaleString()}
{chat.usage.outputTokens.toLocaleString()}
{chat.usage.totalCost > 0 &&
` · $${chat.usage.totalCost.toFixed(2)}`}
</span>
)}
<span
className={classNames(
"conn-dot",
+18 -4
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useParams } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import type { SessionListItem } from "./protocol";
@@ -43,7 +43,7 @@ function Probe({ onVisit }: { onVisit: (path: string) => void }): null {
}
describe("SessionsView", () => {
it("offline sessions are hidden (active only)", () => {
it("offline sessions appear under the Archive section, not in active", () => {
renderView({
sessions: [
session({ id: "on", name: "live" }),
@@ -53,14 +53,28 @@ describe("SessionsView", () => {
const cards = screen.getAllByRole("button", { name: /^Open session/ });
expect(cards.map((c) => c.getAttribute("aria-label"))).toEqual([
"Open session live",
"Open session dead",
]);
expect(screen.getByText("Archive")).toBeInTheDocument();
});
it("empty state message", () => {
renderView();
it("empty state message (after load window)", () => {
vi.useFakeTimers();
renderView({ sessions: [] });
act(() => { vi.advanceTimersByTime(700); });
expect(screen.getByText(/No active sessions/i)).toBeInTheDocument();
vi.useRealTimers();
});
it("skeleton while first load pending", () => {
renderView({ sessions: [] });
expect(screen.getByRole("heading", { name: "Active sessions" })).toBeInTheDocument();
const skel = document.querySelector(".skeleton");
expect(skel).not.toBeNull();
});
it("renders cards sorted by last activity with fallbacks", () => {
renderView({
sessions: [
+100 -44
View File
@@ -1,4 +1,4 @@
import { type MouseEvent, useCallback } from "react";
import { type MouseEvent, useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import type { SessionListItem } from "./protocol";
import { Route } from "./protocol";
@@ -11,12 +11,69 @@ interface Props {
pushToast: (text: string) => void;
}
function SessionCard({
s,
onOpen,
onStop,
}: {
s: SessionListItem;
onOpen: (id: string) => void;
onStop: (e: MouseEvent, s: SessionListItem) => void;
}): React.ReactNode {
return (
<div
className="session-card"
role="button"
tabIndex={0}
aria-label={`Open session ${s.name ?? s.id}`}
onClick={() => onOpen(s.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onOpen(s.id);
}}
>
<span
className={classNames("online-dot", s.online && "on")}
title={s.online ? "online" : "offline"}
/>
<span className="card-body">
<span className="card-title" style={{ display: "block" }}>
{s.name ?? s.repo ?? s.cwd}
</span>
<span className="card-meta" style={{ display: "flex" }}>
{s.repo !== null && <span>{s.repo}</span>}
<span>{s.model}</span>
<span>{relativeTime(s.lastEventAt)}</span>
</span>
</span>
{s.agent && <span className="badge">agent</span>}
{!s.agent && s.online && <span className="badge neutral">local</span>}
{s.agent && (
<button
type="button"
className="icon-btn danger"
aria-label={`Stop container for ${s.name ?? s.id}`}
onClick={(e) => onStop(e, s)}
>
</button>
)}
</div>
);
}
export default function SessionsView({
sessions,
onChanged,
pushToast,
}: Props) {
const navigate = useNavigate();
const [loaded, setLoaded] = useState<boolean>(false);
useEffect(() => {
// first refresh marks the list as loaded (skeletons until then)
const t = window.setTimeout(() => setLoaded(true), 600);
return () => window.clearTimeout(t);
}, []);
const open = useCallback(
(id: string): void => {
@@ -39,59 +96,58 @@ export default function SessionsView({
}
};
// Only active (online) pi sessions are shown; offline transcripts stay
// reachable by direct URL and are dropped from the default list.
const sorted = [...sessions]
.filter((s) => s.online)
.sort(
(a, b) => (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt),
const byActivity = (a: SessionListItem, b: SessionListItem): number =>
(b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt);
const active = sessions.filter((s) => s.online).sort(byActivity);
const archived = sessions.filter((s) => !s.online).sort(byActivity);
if (!loaded && sessions.length === 0) {
return (
<div className="page" aria-busy="true">
<div className="page-head">
<h1>Active sessions</h1>
</div>
<div className="skeleton" />
<div className="skeleton" />
<div className="skeleton" />
</div>
);
}
return (
<div className="page">
<div className="page-head">
<h1>Active sessions</h1>
{sorted.length === 0 && (
<span className="count">
{active.length} active · {archived.length} archived
</span>
</div>
{active.length === 0 && (
<p className="empty">No active sessions. Spawn one from the sidebar.</p>
)}
{sorted.map((s) => (
<div
{active.map((s) => (
<SessionCard
key={s.id}
className="session-card"
role="button"
tabIndex={0}
aria-label={`Open session ${s.name ?? s.id}`}
onClick={() => open(s.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") open(s.id);
}}
>
<span
className={classNames("online-dot", s.online && "on")}
title={s.online ? "online" : "offline"}
s={s}
onOpen={open}
onStop={(e, ss) => void stop(e, ss)}
/>
<span className="card-body">
<span className="card-title" style={{ display: "block" }}>
{s.name ?? s.repo ?? s.cwd}
</span>
<span className="card-meta" style={{ display: "flex" }}>
{s.repo !== null && <span>{s.repo}</span>}
<span>{s.model}</span>
<span>{relativeTime(s.lastEventAt)}</span>
</span>
</span>
{s.agent && <span className="badge">agent</span>}
{s.agent && (
<button
type="button"
className="icon-btn danger"
aria-label={`Stop container for ${s.name ?? s.id}`}
onClick={(e) => void stop(e, s)}
>
</button>
)}
</div>
))}
{archived.length > 0 && (
<>
<div className="sidebar-section-label" style={{ marginTop: 22 }}>
Archive
</div>
{archived.map((s) => (
<SessionCard
key={s.id}
s={s}
onOpen={open}
onStop={(e, ss) => void stop(e, ss)}
/>
))}
</>
)}
</div>
);
}
+26
View File
@@ -420,3 +420,29 @@ describe("SpawnView spawn+poll", () => {
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
});
});
describe("SpawnSteps", () => {
it("renders progress steps matching job state", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sp1", containerId: "" };
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
return [];
});
const store = makeStore({
spawnJobs: [{ sessionId: "sp1", repo: "g/p", state: "building", containerId: "" }],
});
render(tree(store));
await flush();
fireEvent.click(screen.getByText("g/p"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
const steps = document.querySelectorAll(".spawn-progress .step");
expect(steps).toHaveLength(4);
expect(steps[0]?.className).toContain("done");
expect(steps[1]?.className).toContain("current");
expect(steps[2]?.className).toBe("step");
});
});
+29
View File
@@ -19,6 +19,27 @@ interface Props {
pushToast: (text: string) => void;
}
const SPAWN_STEPS: string[] = ["cloning", "building", "creating", "running"];
function SpawnSteps({ state }: { state: string }): React.ReactNode {
const idx = SPAWN_STEPS.indexOf(state);
if (state === "error") return null;
return (
<div className="spawn-progress" aria-label={`spawn progress: ${state}`}>
{SPAWN_STEPS.map((step, i) => (
<span
key={step}
className={
idx > i ? "step done" : idx === i ? "step current" : "step"
}
title={step}
/>
))}
</div>
);
}
export default function SpawnView({ store, pushToast }: Props) {
const navigate = useNavigate();
@@ -146,6 +167,13 @@ export default function SpawnView({ store, pushToast }: Props) {
}, POLL_MS);
};
const jobState = (): string => {
const job: SpawnJob | undefined = store.spawnJobs.find(
(j) => j.sessionId === spawning?.sessionId,
);
return job?.state ?? "";
};
const jobLine = (): string => {
if (spawning === null) return "";
const job: SpawnJob | undefined = store.spawnJobs.find(
@@ -208,6 +236,7 @@ export default function SpawnView({ store, pushToast }: Props) {
{spawning !== null && (
<div className="spawn-card">
<h2>Spawning</h2>
<SpawnSteps state={jobState()} />
<p className="spawn-job-line">{jobLine()}</p>
<p className="repo-meta">
container {spawning.containerId.slice(0, 12)}
+12 -1
View File
@@ -46,6 +46,7 @@ export interface ChatDerivation {
messages: ChatMessage[];
tools: Map<string, ToolState>;
busy: boolean;
usage: { inputTokens: number; outputTokens: number; totalCost: number };
}
// The plugin mirrors pi extension events verbatim: tool args arrive as a raw
@@ -66,6 +67,7 @@ export function deriveChat(events: EventFrame[]): ChatDerivation {
const tools = new Map<string, ToolState>();
let stream: { id: string; text: string } | null = null;
let busy = false;
const usage = { inputTokens: 0, outputTokens: 0, totalCost: 0 };
for (const e of events) {
switch (e.type) {
@@ -95,6 +97,15 @@ export function deriveChat(events: EventFrame[]): ChatDerivation {
case "agent_start":
busy = true;
break;
case "agent_end": {
const u = e.usage;
if (u !== undefined) {
usage.inputTokens += u.inputTokens ?? 0;
usage.outputTokens += u.outputTokens ?? 0;
usage.totalCost += u.totalCost ?? 0;
}
break;
}
case "agent_settled":
busy = false;
break;
@@ -138,7 +149,7 @@ export function deriveChat(events: EventFrame[]): ChatDerivation {
});
}
return { messages, tools, busy: busy || stream !== null };
return { messages, tools, busy: busy || stream !== null, usage };
}
// ---------- todo / subagent derivation (client-side, per PROTOCOL.md) ----------
+891 -214
View File
File diff suppressed because it is too large Load Diff