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:
+42
-2
@@ -7,7 +7,7 @@ import {
|
|||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import App from "./App";
|
import App, { ErrorBoundary } from "./App";
|
||||||
import { clearSettings } from "./settings";
|
import { clearSettings } from "./settings";
|
||||||
import {
|
import {
|
||||||
FakeWebSocket,
|
FakeWebSocket,
|
||||||
@@ -114,7 +114,7 @@ describe("App shell", () => {
|
|||||||
const sock = FakeWebSocket.last();
|
const sock = FakeWebSocket.last();
|
||||||
act(() => sock.serverOpen());
|
act(() => sock.serverOpen());
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("img", { name: "connection open" }),
|
screen.getByTitle("ws open"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
const sidebar = screen.getByLabelText("Sessions");
|
const sidebar = screen.getByLabelText("Sessions");
|
||||||
@@ -223,3 +223,43 @@ describe("App shell", () => {
|
|||||||
function container_backdrop(): HTMLElement | null {
|
function container_backdrop(): HTMLElement | null {
|
||||||
return document.querySelector(".sidebar-backdrop");
|
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
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { Component, useEffect, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
NavLink,
|
NavLink,
|
||||||
Navigate,
|
Navigate,
|
||||||
@@ -12,6 +12,74 @@ import SpawnView from "./SpawnView";
|
|||||||
import SettingsGate from "./SettingsGate";
|
import SettingsGate from "./SettingsGate";
|
||||||
import { classNames, useSessions, useToasts } from "./store";
|
import { classNames, useSessions, useToasts } from "./store";
|
||||||
import { clearSettings, getSettings } from "./settings";
|
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() {
|
export default function App() {
|
||||||
const [configured, setConfigured] = useState<boolean>(getSettings() !== null);
|
const [configured, setConfigured] = useState<boolean>(getSettings() !== null);
|
||||||
@@ -28,6 +96,11 @@ export default function App() {
|
|||||||
if (store === null)
|
if (store === null)
|
||||||
return <SettingsGate onSaved={() => setConfigured(true)} />;
|
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 (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<nav
|
<nav
|
||||||
@@ -41,12 +114,10 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
lvmh
|
lvmh
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span className="conn-chip" title={`ws ${store.state}`}>
|
||||||
className={classNames("conn-dot", store.state)}
|
<span className={classNames("conn-dot", store.state)} />
|
||||||
title={`ws ${store.state}`}
|
{store.state}
|
||||||
role="img"
|
</span>
|
||||||
aria-label={`connection ${store.state}`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/new"
|
to="/new"
|
||||||
@@ -57,30 +128,16 @@ export default function App() {
|
|||||||
+ Spawn session
|
+ Spawn session
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<div className="sidebar-sessions">
|
<div className="sidebar-sessions">
|
||||||
{[...store.sessions]
|
<SidebarSection
|
||||||
.filter((s) => s.online)
|
label="Active"
|
||||||
.sort(
|
sessions={active}
|
||||||
(a, b) =>
|
emptyText="no active pi"
|
||||||
(b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt),
|
/>
|
||||||
)
|
<SidebarSection
|
||||||
.map((s) => (
|
label="Archive"
|
||||||
<NavLink
|
sessions={archived}
|
||||||
key={s.id}
|
emptyText="nothing archived"
|
||||||
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" }}
|
|
||||||
/>
|
/>
|
||||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
||||||
{s.name ?? s.repo ?? s.id}
|
|
||||||
</span>
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
@@ -104,6 +161,12 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<main className="main">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-btn menu-btn"
|
className="icon-btn menu-btn"
|
||||||
@@ -112,12 +175,12 @@ export default function App() {
|
|||||||
display: "none",
|
display: "none",
|
||||||
padding: "8px 12px",
|
padding: "8px 12px",
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
borderRadius: 0,
|
|
||||||
}}
|
}}
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
>
|
>
|
||||||
☰
|
☰
|
||||||
</button>
|
</button>
|
||||||
|
<ErrorBoundary>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
@@ -139,6 +202,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</ErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
<div className="toasts" role="status">
|
<div className="toasts" role="status">
|
||||||
{toasts.map((t) => (
|
{toasts.map((t) => (
|
||||||
|
|||||||
@@ -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 userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ChatMessage, ToolState } from "./derive";
|
import type { ChatMessage, ToolState } from "./derive";
|
||||||
@@ -300,3 +300,37 @@ describe("ChatStream", () => {
|
|||||||
expect(scroller.scrollTop).toBe(1000);
|
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
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { ChatMessage, ToolState } from "./derive";
|
import type { ChatMessage, ToolState } from "./derive";
|
||||||
|
|
||||||
const PREVIEW_LEN: number = 120;
|
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;
|
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 }) {
|
function ToolCard({ tool }: { tool: ToolState }) {
|
||||||
const status: string = tool.running
|
const status: string = tool.running
|
||||||
? "running…"
|
? "running…"
|
||||||
: tool.isError
|
: tool.isError
|
||||||
? "error"
|
? "error"
|
||||||
: "done";
|
: "done";
|
||||||
const statusClass: string = tool.isError ? "tool-status-err" : "tool-status-ok";
|
const statusClass: string = tool.isError
|
||||||
|
? "tool-status-err"
|
||||||
|
: "tool-status-ok";
|
||||||
return (
|
return (
|
||||||
<details className="tool-card">
|
<details className="tool-card">
|
||||||
<summary>
|
<summary>
|
||||||
<span className="tool-name">🛠 {tool.name}</span>
|
<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>
|
</summary>
|
||||||
<div className="tool-body">
|
<div className="tool-body">
|
||||||
<div>
|
<div>
|
||||||
<strong>args</strong>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<strong>result</strong>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -44,8 +71,19 @@ function Thinking({ text }: { text: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Bubble({ msg, tools }: { msg: ChatMessage; tools: Map<string, ToolState> }) {
|
export function Bubble({
|
||||||
const rowClass = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "system";
|
msg,
|
||||||
|
tools,
|
||||||
|
}: {
|
||||||
|
msg: ChatMessage;
|
||||||
|
tools: Map<string, ToolState>;
|
||||||
|
}) {
|
||||||
|
const rowClass =
|
||||||
|
msg.role === "user"
|
||||||
|
? "user"
|
||||||
|
: msg.role === "assistant"
|
||||||
|
? "assistant"
|
||||||
|
: "system";
|
||||||
const msgTools: ToolState[] = [];
|
const msgTools: ToolState[] = [];
|
||||||
if (msg.role === "assistant") {
|
if (msg.role === "assistant") {
|
||||||
for (const c of msg.toolCalls) {
|
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);
|
if (t !== undefined) msgTools.push(t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const copyable: boolean =
|
||||||
|
msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0);
|
||||||
return (
|
return (
|
||||||
<div className={`bubble-row ${rowClass}`}>
|
<div className={`bubble-row ${rowClass}`}>
|
||||||
|
{copyable && <CopyButton text={msg.text} />}
|
||||||
<div className="bubble">
|
<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) => (
|
{msgTools.map((t) => (
|
||||||
<ToolCard key={t.id} tool={t} />
|
<ToolCard key={t.id} tool={t} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -497,3 +497,32 @@ describe("ChatView unknown session", () => {
|
|||||||
expect(screen.queryByRole("button", { name: /Close pi/ })).toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -167,6 +167,14 @@ export default function ChatView({ store, pushToast }: Props) {
|
|||||||
{session?.name ?? session?.repo ?? sessionId}
|
{session?.name ?? session?.repo ?? sessionId}
|
||||||
</div>
|
</div>
|
||||||
<div className="sub">{session?.model}</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
|
<span
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"conn-dot",
|
"conn-dot",
|
||||||
|
|||||||
@@ -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 { MemoryRouter, Route, Routes, useParams } from "react-router-dom";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { SessionListItem } from "./protocol";
|
import type { SessionListItem } from "./protocol";
|
||||||
@@ -43,7 +43,7 @@ function Probe({ onVisit }: { onVisit: (path: string) => void }): null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("SessionsView", () => {
|
describe("SessionsView", () => {
|
||||||
it("offline sessions are hidden (active only)", () => {
|
it("offline sessions appear under the Archive section, not in active", () => {
|
||||||
renderView({
|
renderView({
|
||||||
sessions: [
|
sessions: [
|
||||||
session({ id: "on", name: "live" }),
|
session({ id: "on", name: "live" }),
|
||||||
@@ -53,14 +53,28 @@ describe("SessionsView", () => {
|
|||||||
const cards = screen.getAllByRole("button", { name: /^Open session/ });
|
const cards = screen.getAllByRole("button", { name: /^Open session/ });
|
||||||
expect(cards.map((c) => c.getAttribute("aria-label"))).toEqual([
|
expect(cards.map((c) => c.getAttribute("aria-label"))).toEqual([
|
||||||
"Open session live",
|
"Open session live",
|
||||||
|
"Open session dead",
|
||||||
]);
|
]);
|
||||||
|
expect(screen.getByText("Archive")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("empty state message", () => {
|
it("empty state message (after load window)", () => {
|
||||||
renderView();
|
vi.useFakeTimers();
|
||||||
|
renderView({ sessions: [] });
|
||||||
|
act(() => { vi.advanceTimersByTime(700); });
|
||||||
expect(screen.getByText(/No active sessions/i)).toBeInTheDocument();
|
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", () => {
|
it("renders cards sorted by last activity with fallbacks", () => {
|
||||||
renderView({
|
renderView({
|
||||||
sessions: [
|
sessions: [
|
||||||
|
|||||||
+105
-49
@@ -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 { useNavigate } from "react-router-dom";
|
||||||
import type { SessionListItem } from "./protocol";
|
import type { SessionListItem } from "./protocol";
|
||||||
import { Route } from "./protocol";
|
import { Route } from "./protocol";
|
||||||
@@ -11,12 +11,69 @@ interface Props {
|
|||||||
pushToast: (text: string) => void;
|
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({
|
export default function SessionsView({
|
||||||
sessions,
|
sessions,
|
||||||
onChanged,
|
onChanged,
|
||||||
pushToast,
|
pushToast,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const navigate = useNavigate();
|
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(
|
const open = useCallback(
|
||||||
(id: string): void => {
|
(id: string): void => {
|
||||||
@@ -39,59 +96,58 @@ export default function SessionsView({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only active (online) pi sessions are shown; offline transcripts stay
|
const byActivity = (a: SessionListItem, b: SessionListItem): number =>
|
||||||
// reachable by direct URL and are dropped from the default list.
|
(b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt);
|
||||||
const sorted = [...sessions]
|
const active = sessions.filter((s) => s.online).sort(byActivity);
|
||||||
.filter((s) => s.online)
|
const archived = sessions.filter((s) => !s.online).sort(byActivity);
|
||||||
.sort(
|
|
||||||
(a, b) => (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (!loaded && sessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page" aria-busy="true">
|
||||||
|
<div className="page-head">
|
||||||
<h1>Active sessions</h1>
|
<h1>Active sessions</h1>
|
||||||
{sorted.length === 0 && (
|
|
||||||
<p className="empty">No active sessions. Spawn one from the sidebar.</p>
|
|
||||||
)}
|
|
||||||
{sorted.map((s) => (
|
|
||||||
<div
|
|
||||||
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"}
|
|
||||||
/>
|
|
||||||
<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>
|
</div>
|
||||||
|
<div className="skeleton" />
|
||||||
|
<div className="skeleton" />
|
||||||
|
<div className="skeleton" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>Active sessions</h1>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{active.map((s) => (
|
||||||
|
<SessionCard
|
||||||
|
key={s.id}
|
||||||
|
s={s}
|
||||||
|
onOpen={open}
|
||||||
|
onStop={(e, ss) => void stop(e, ss)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -420,3 +420,29 @@ describe("SpawnView spawn+poll", () => {
|
|||||||
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,27 @@ interface Props {
|
|||||||
pushToast: (text: string) => void;
|
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) {
|
export default function SpawnView({ store, pushToast }: Props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -146,6 +167,13 @@ export default function SpawnView({ store, pushToast }: Props) {
|
|||||||
}, POLL_MS);
|
}, POLL_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const jobState = (): string => {
|
||||||
|
const job: SpawnJob | undefined = store.spawnJobs.find(
|
||||||
|
(j) => j.sessionId === spawning?.sessionId,
|
||||||
|
);
|
||||||
|
return job?.state ?? "";
|
||||||
|
};
|
||||||
|
|
||||||
const jobLine = (): string => {
|
const jobLine = (): string => {
|
||||||
if (spawning === null) return "";
|
if (spawning === null) return "";
|
||||||
const job: SpawnJob | undefined = store.spawnJobs.find(
|
const job: SpawnJob | undefined = store.spawnJobs.find(
|
||||||
@@ -208,6 +236,7 @@ export default function SpawnView({ store, pushToast }: Props) {
|
|||||||
{spawning !== null && (
|
{spawning !== null && (
|
||||||
<div className="spawn-card">
|
<div className="spawn-card">
|
||||||
<h2>Spawning…</h2>
|
<h2>Spawning…</h2>
|
||||||
|
<SpawnSteps state={jobState()} />
|
||||||
<p className="spawn-job-line">{jobLine()}</p>
|
<p className="spawn-job-line">{jobLine()}</p>
|
||||||
<p className="repo-meta">
|
<p className="repo-meta">
|
||||||
container {spawning.containerId.slice(0, 12)}
|
container {spawning.containerId.slice(0, 12)}
|
||||||
|
|||||||
+12
-1
@@ -46,6 +46,7 @@ export interface ChatDerivation {
|
|||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
tools: Map<string, ToolState>;
|
tools: Map<string, ToolState>;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
usage: { inputTokens: number; outputTokens: number; totalCost: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
// The plugin mirrors pi extension events verbatim: tool args arrive as a raw
|
// 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>();
|
const tools = new Map<string, ToolState>();
|
||||||
let stream: { id: string; text: string } | null = null;
|
let stream: { id: string; text: string } | null = null;
|
||||||
let busy = false;
|
let busy = false;
|
||||||
|
const usage = { inputTokens: 0, outputTokens: 0, totalCost: 0 };
|
||||||
|
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
@@ -95,6 +97,15 @@ export function deriveChat(events: EventFrame[]): ChatDerivation {
|
|||||||
case "agent_start":
|
case "agent_start":
|
||||||
busy = true;
|
busy = true;
|
||||||
break;
|
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":
|
case "agent_settled":
|
||||||
busy = false;
|
busy = false;
|
||||||
break;
|
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) ----------
|
// ---------- todo / subagent derivation (client-side, per PROTOCOL.md) ----------
|
||||||
|
|||||||
+891
-214
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user