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";
|
||||
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
@@ -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) => (
|
||||
|
||||
@@ -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
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user