web: integrate QoL slices 1-5 (model switch/rename, history deletes, usage stats, repo prepare+badges+imageUsed, markdown/search/FAB/timestamps) — 263 tests, ≥95% all metrics

This commit is contained in:
Raphael Westphal
2026-08-19 10:35:40 +02:00
parent 698b742cb3
commit c8ad68aaea
17 changed files with 2618 additions and 170 deletions
-46
View File
@@ -1,46 +0,0 @@
# Integration task: merge 5 web slices (already-recovered blobs in ../../.pi/scratch/rec4/)
Base: this worktree at current master + the ChatView.tsx from slice 1 (model/rename —
already placed). The other slices each edited overlapping files. Merge ALL slices.
## Blob map (.pi/scratch/rec4/<sha>.txt)
- Slice 1 (model switch + rename): ChatView=e2c7b1c1 (PLACED), derive=6c499294? NO —
check: derive blobs: 6c499294 (has notice? grep error_notice), 73f1de97, abf953bf.
ChatView.test=0617e495, protocol=29b7795f is slice-5's, slice-1 protocol is
9aec478e? — IDENTIFY by features:
- s1: catalogCache, SessionModel routes, error_notice/notice-line, rename
- s2: Clear history menu, Delete session, Route.Session, trash button (SessionsView=8ab1292a? grep)
- s3: SessionStats/Route.Stats, formatTokens in store.ts, SessionsView global line
- s4 (web-only QoL): renderMarkdown in ChatStream=01f25bac(also has FAB/search),
ChatView=17c34004 (timestamps/placeholder/ArrowUp), derive=?? (ts field), css=5ba557b7 or 4b643f25 or 19c9becd (largest superset)
- s5: SpawnView badges/prepare (SpawnView=ec5f2892? grep prepare), protocol RepoPrepare/Repos/imageUsed, SpawnView.test=996b2e63
## Procedure
1. protocol.ts: start from master's, add ALL routes/types from s1+s2+s3+s5 blobs
(SessionModel, ModelCatalog, Session(id) DELETE, ClearHistoryResponse, Stats,
SessionStats, StatsTotals, Repos/RepoImage/RepoPrepare, imageUsed on SpawnResponse).
2. ChatView.tsx: base = e2c7b1c1 (placed). Graft: s2 overflow menu (Clear/Delete,
from a8c6b3b0), s3 stats chip fetch (from 5ef417c8), s4 QoL pieces that live in
ChatView (search toggle+hotkey, ⋯ timestamps toggle, placeholder switch,
ArrowUp recall — from 17c34004).
3. ChatStream.tsx: base = 01f25bac (s4: markdown+FAB+search+ts). Verify it compiles
against s1's notice-line message shape; adapt.
4. derive.ts: master + s1 notice message + s4 ts field (whichever blob has both, else merge).
5. store.ts: master + s3 formatTokens (+ any stats helpers).
6. SessionsView.tsx: master + s2 trash + s3 global stats line.
7. SpawnView.tsx: master + s5 badges/prepare/imageUsed.
8. index.css: take the LARGEST blob (superset ordering: 19c9becd 22628 > 5ba557b7 21318
> 4b643f25 21285) and verify it contains all classes used by merged components;
add missing rules from the others.
9. Tests: merge test blobs the same way (ChatView.test from 0617e495 + grafts from
s2/s3/s4 test files; SpawnView.test=996b2e63; ChatStream tests inside 1c6e7edd?
identify; derive tests). Aim: keep every test that matches merged features; drop
tests for unmerged ones.
10. Gates: bunx tsc@5.9 --noEmit && bun run build && bun run coverage — ALL four
metrics ≥95. Iterate until green. You may simplify/drop marginal features'
tests if a graft is cursed, but NEVER ship a failing suite.
Work tree = this worktree; blobs are read-only. Report per-file what you took from
which slice + final verbatim coverage/build tails.
+9
View File
@@ -60,6 +60,15 @@ const sessions: SessionListItem[] = [
function seedApi(): void { function seedApi(): void {
mockFetchJson((url) => { mockFetchJson((url) => {
if (url.endsWith("/stats"))
return {
turns: 0,
inputTokens: 0,
outputTokens: 0,
totalCost: 0,
sessionsCount: 0,
onlineCount: 0,
};
if (url.includes("/api/sessions")) if (url.includes("/api/sessions"))
return url.includes("/events") ? [] : sessions; return url.includes("/events") ? [] : sessions;
if (url.includes("/api/gitlab/status")) if (url.includes("/api/gitlab/status"))
+374 -21
View File
@@ -1,8 +1,12 @@
import { act, fireEvent, render, screen } from "@testing-library/react"; import { act, fireEvent, render, screen } 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatMessage, ToolState } from "./derive"; import type { ChatMessage, ToolState } from "./derive";
import ChatStream, { Bubble, TypingIndicator } from "./ChatStream"; import ChatStream, {
Bubble,
TypingIndicator,
renderMarkdown,
} from "./ChatStream";
function msg(partial: Partial<ChatMessage>): ChatMessage { function msg(partial: Partial<ChatMessage>): ChatMessage {
return { return {
@@ -13,6 +17,7 @@ function msg(partial: Partial<ChatMessage>): ChatMessage {
toolCalls: [], toolCalls: [],
toolCallId: null, toolCallId: null,
streaming: false, streaming: false,
ts: 0,
...partial, ...partial,
}; };
} }
@@ -27,6 +32,31 @@ const tool = (p: Partial<ToolState>): ToolState => ({
...p, ...p,
}); });
function stream(p: {
messages: ChatMessage[];
busy: boolean;
hasOlder?: boolean;
loadingOlder?: boolean;
onOlder?: () => void;
searchOpen?: boolean;
onSearchClose?: () => void;
showTs?: boolean;
}): React.ReactElement {
return (
<ChatStream
messages={p.messages}
tools={new Map()}
busy={p.busy}
hasOlder={p.hasOlder ?? false}
loadingOlder={p.loadingOlder ?? false}
onLoadOlder={p.onOlder ?? (() => undefined)}
searchOpen={p.searchOpen ?? false}
onSearchClose={p.onSearchClose ?? (() => undefined)}
showTs={p.showTs ?? false}
/>
);
}
describe("Bubble", () => { describe("Bubble", () => {
it("renders plain text per role class", () => { it("renders plain text per role class", () => {
const { container } = render( const { container } = render(
@@ -39,6 +69,18 @@ describe("Bubble", () => {
expect(container.textContent).toContain("hi there"); expect(container.textContent).toContain("hi there");
}); });
it("notice messages render as a ⚠ line, not a chat bubble", () => {
const { container } = render(
<Bubble
msg={msg({ role: "system", text: "model not found", notice: true })}
tools={new Map()}
/>,
);
expect(container.querySelector(".notice-line")).not.toBeNull();
expect(container.textContent).toContain("⚠ model not found");
expect(container.querySelector(".bubble")).toBeNull();
});
it("renders empty assistant text as nothing but shows nothing when empty", () => { it("renders empty assistant text as nothing but shows nothing when empty", () => {
const { container } = render( const { container } = render(
<Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />, <Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />,
@@ -195,25 +237,6 @@ describe("TypingIndicator", () => {
}); });
describe("ChatStream", () => { describe("ChatStream", () => {
function stream(p: {
messages: ChatMessage[];
busy: boolean;
hasOlder?: boolean;
loadingOlder?: boolean;
onOlder?: () => void;
}): React.ReactElement {
return (
<ChatStream
messages={p.messages}
tools={new Map()}
busy={p.busy}
hasOlder={p.hasOlder ?? false}
loadingOlder={p.loadingOlder ?? false}
onLoadOlder={p.onOlder ?? (() => undefined)}
/>
);
}
it("renders messages and typing indicator while busy with no open stream", () => { it("renders messages and typing indicator while busy with no open stream", () => {
const { container, rerender } = render( const { container, rerender } = render(
stream({ messages: [msg({ key: "a", text: "one" })], busy: true }), stream({ messages: [msg({ key: "a", text: "one" })], busy: true }),
@@ -339,6 +362,333 @@ describe("ChatStream", () => {
}); });
}); });
describe("renderMarkdown", () => {
const md = (text: string, query = ""): HTMLElement => {
const { container } = render(
<div data-testid="md">{renderMarkdown(text, query)}</div>,
);
return container.querySelector('[data-testid="md"]') as HTMLElement;
};
it("plain text passes through unchanged with no element wrappers", () => {
const el = md("hello world");
expect(el.textContent).toBe("hello world");
expect(el.children).toHaveLength(0);
});
it("``` fences render as pre.md-code with the fenced body", () => {
const el = md("before\n```js\nlet a = 1\n```\nafter");
const pre = el.querySelector("pre.md-code");
expect(pre).not.toBeNull();
expect(pre?.textContent).toBe("let a = 1");
expect(el.textContent).toContain("before");
expect(el.textContent).toContain("after");
expect(el.querySelectorAll("pre")).toHaveLength(1);
});
it("an unterminated fence (streaming) renders the tail as code", () => {
const el = md("head\n```\ncode tail");
const pre = el.querySelector("pre.md-code");
expect(pre?.textContent).toBe("code tail");
expect(el.textContent).toContain("head");
});
it("`inline` code renders as <code>", () => {
const el = md("use `npm run` now");
expect(el.querySelector("code")?.textContent).toBe("npm run");
expect(el.textContent).toBe("use npm run now");
});
it("**bold** and *italic*", () => {
const el = md("**b** and *i*");
expect(el.querySelector("strong")?.textContent).toBe("b");
expect(el.querySelector("em")?.textContent).toBe("i");
// leading and trailing plain segments (empty ones are skipped)
const edges = md("**b**");
expect(edges.children).toHaveLength(1);
expect(edges.textContent).toBe("b");
});
it("[t](http…) links open in a new tab with noopener", () => {
const el = md("see [docs](https://x.dev/a?b=1) and [m](http://y.org)");
const links = el.querySelectorAll("a");
expect(links).toHaveLength(2);
expect(links[0]).toHaveAttribute("href", "https://x.dev/a?b=1");
expect(links[0]).toHaveAttribute("target", "_blank");
expect(links[0]?.getAttribute("rel")).toContain("noopener");
expect(links[0]?.textContent).toBe("docs");
});
it("javascript: (and other non-http) links are dropped, text kept", () => {
const el = md("[x](javascript:alert(1)) and [y](data:text/plain,hi)");
expect(el.querySelector("a")).toBeNull();
expect(el.textContent).toContain("[x](javascript:alert(1))");
expect(el.textContent).toContain("[y](data:text/plain,hi)");
});
it("query highlights match as mark.hit, also inside bold", () => {
const el = md("find the needle here and **needle bold**", "NEEDLE");
const marks = el.querySelectorAll("mark.hit");
expect(marks).toHaveLength(2);
expect(marks[0]?.textContent).toBe("needle");
expect(el.querySelector("strong mark.hit")).not.toBeNull();
});
it("a query with no match renders plain text", () => {
const el = md("nothing to see", "zzz");
expect(el.querySelector("mark")).toBeNull();
expect(el.textContent).toBe("nothing to see");
});
it("code fences and inline code stay unhighlighted", () => {
const el = md("```\nneedle\n```\nand `needle` done", "needle");
expect(el.querySelectorAll("mark.hit")).toHaveLength(0);
expect(el.textContent).toContain("needle");
});
it("renders inside a bubble text node", () => {
render(
<Bubble
msg={msg({ text: "**b** `c` [l](https://a.dev)" })}
tools={new Map()}
/>,
);
expect(screen.getByText("b").tagName).toBe("STRONG");
expect(screen.getByText("c").tagName).toBe("CODE");
expect(screen.getByText("l")).toHaveAttribute("href", "https://a.dev");
});
});
describe("ChatStream search overlay", () => {
const needles = (): ChatMessage[] => [
msg({ key: "a", role: "user", text: "alpha NEEDLE one" }),
msg({ key: "b", role: "assistant", text: "no match here" }),
msg({ key: "c", role: "assistant", text: "second needle" }),
];
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn();
});
afterEach(() => {
delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView;
});
it("filters messages case-insensitively over text, shows n/m and marks hits", () => {
render(stream({ messages: needles(), busy: false, searchOpen: true }));
const input = screen.getByLabelText("Search");
fireEvent.change(input, { target: { value: "needle" } });
expect(screen.getByText("1/2")).toBeInTheDocument();
expect(document.querySelectorAll("mark.hit")).toHaveLength(2);
});
it("matches also thinking and tool names, not just text", () => {
const messages = [
msg({ key: "t", text: "x", thinking: "hidden thought" }),
msg({
key: "u",
text: "plain",
toolCalls: [{ id: "c1", name: "deploy", argsJson: "{}" }],
}),
msg({ key: "v", text: "nothing" }),
];
const { rerender } = render(
stream({ messages, busy: false, searchOpen: true }),
);
fireEvent.change(screen.getByLabelText("Search"), {
target: { value: "hidden" },
});
expect(screen.getByText("1/1")).toBeInTheDocument();
rerender(
stream({
messages: [messages[1]!, messages[2]!],
busy: false,
searchOpen: true,
}),
);
fireEvent.change(screen.getByLabelText("Search"), {
target: { value: "deploy" },
});
expect(screen.getByText("1/1")).toBeInTheDocument();
});
it("Enter/Shift+Enter cycle matches and scroll them into view", () => {
render(stream({ messages: needles(), busy: false, searchOpen: true }));
const input = screen.getByLabelText("Search");
fireEvent.change(input, { target: { value: "needle" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("2/2")).toBeInTheDocument();
expect(Element.prototype.scrollIntoView).toHaveBeenCalled();
expect(
document.querySelector('[data-key="c"]')?.closest(".msg-current"),
).not.toBeNull();
fireEvent.keyDown(input, { key: "Enter" }); // wraps around
expect(screen.getByText("1/2")).toBeInTheDocument();
fireEvent.keyDown(input, { key: "Enter", shiftKey: true }); // back to 2
expect(screen.getByText("2/2")).toBeInTheDocument();
});
it("no matches shows 0/0 and Enter is a no-op", () => {
render(stream({ messages: needles(), busy: false, searchOpen: true }));
const input = screen.getByLabelText("Search");
fireEvent.change(input, { target: { value: "zzz" } });
expect(screen.getByText("0/0")).toBeInTheDocument();
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("0/0")).toBeInTheDocument();
});
it("empty query clears highlights and count", () => {
const { rerender } = render(
stream({ messages: needles(), busy: false, searchOpen: true }),
);
const input = screen.getByLabelText("Search");
fireEvent.change(input, { target: { value: "needle" } });
expect(document.querySelectorAll("mark.hit")).toHaveLength(2);
fireEvent.change(input, { target: { value: "" } });
expect(document.querySelectorAll("mark.hit")).toHaveLength(0);
expect(screen.queryByText(/^\d+\/\d+$/)).toBeNull();
// same when reopened after closing with a query typed
fireEvent.change(input, { target: { value: "needle" } });
rerender(stream({ messages: needles(), busy: false, searchOpen: false }));
rerender(stream({ messages: needles(), busy: false, searchOpen: true }));
const reopened = screen.getByLabelText("Search") as HTMLInputElement;
expect(reopened.value).toBe("");
expect(document.querySelectorAll("mark.hit")).toHaveLength(0);
});
it("Esc closes the overlay via onSearchClose", () => {
const onSearchClose = vi.fn();
render(
stream({
messages: needles(),
busy: false,
searchOpen: true,
onSearchClose,
}),
);
fireEvent.keyDown(screen.getByLabelText("Search"), { key: "Escape" });
expect(onSearchClose).toHaveBeenCalledTimes(1);
});
it("a shrunken match set clamps the cursor", () => {
const { rerender } = render(
stream({ messages: needles(), busy: false, searchOpen: true }),
);
const input = screen.getByLabelText("Search");
fireEvent.change(input, { target: { value: "needle" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("2/2")).toBeInTheDocument();
// live events remove the second match under the cursor
rerender(
stream({
messages: [needles()[0]!, needles()[1]!],
busy: false,
searchOpen: true,
}),
);
expect(screen.getByText("1/1")).toBeInTheDocument();
});
});
describe("ChatStream scroll FAB", () => {
function sized(h: number, top: number): void {
const scroller = document.querySelector(".chat-scroll") as HTMLElement;
Object.defineProperty(scroller, "scrollHeight", {
configurable: true,
value: h,
});
Object.defineProperty(scroller, "clientHeight", {
configurable: true,
value: 300,
});
Object.defineProperty(scroller, "scrollTop", {
configurable: true,
writable: true,
value: top,
});
fireEvent.scroll(scroller);
}
it("hidden while near the bottom, appears past 400px away", () => {
render(stream({ messages: [msg({ key: "a" })], busy: false }));
sized(2000, 1700); // 0px away
expect(screen.queryByRole("button", { name: "Jump to latest" })).toBeNull();
sized(2000, 1301); // 399px away
expect(screen.queryByRole("button", { name: "Jump to latest" })).toBeNull();
sized(2000, 1299); // 401px away
expect(
screen.getByRole("button", { name: "Jump to latest" }),
).toBeInTheDocument();
});
it("clicking the FAB pins and jumps to the bottom", () => {
const { rerender } = render(
stream({ messages: [msg({ key: "a" })], busy: false }),
);
sized(2000, 0);
const fab = screen.getByRole("button", { name: "Jump to latest" });
fireEvent.click(fab);
const scroller = document.querySelector(".chat-scroll") as HTMLElement;
expect(scroller.scrollTop).toBe(2000);
expect(screen.queryByRole("button", { name: "Jump to latest" })).toBeNull();
// pinned again: a new message keeps the view at the bottom (no FAB)
rerender(
stream({ messages: [msg({ key: "a" }), msg({ key: "b" })], busy: false }),
);
expect(scroller.scrollTop).toBe(2000);
expect(screen.queryByRole("button", { name: "Jump to latest" })).toBeNull();
});
});
describe("ChatStream timestamps", () => {
const TS: number = Date.UTC(2024, 0, 2, 10, 30);
it("hidden by default, title attr on the row always", () => {
const { container, rerender } = render(
stream({
messages: [msg({ key: "a", ts: TS })],
busy: false,
showTs: false,
}),
);
expect(container.querySelector(".ts")).toBeNull();
expect(container.querySelector(".bubble-row")).toHaveAttribute(
"title",
"2024-01-02T10:30:00.000Z",
);
rerender(
stream({
messages: [msg({ key: "a", ts: TS })],
busy: false,
showTs: true,
}),
);
expect(container.querySelector(".ts")).not.toBeNull();
expect(container.querySelector(".ts")).toHaveAttribute(
"title",
"2024-01-02T10:30:00.000Z",
);
expect(container.querySelector(".ts")?.textContent).toMatch(/^\d{2}:\d{2}/);
});
it("ts 0 (streaming) renders nothing", () => {
const { container } = render(
stream({
messages: [msg({ key: "a", ts: 0, streaming: true })],
busy: false,
showTs: true,
}),
);
expect(container.querySelector(".ts")).toBeNull();
expect(container.querySelector(".bubble-row")).not.toHaveAttribute("title");
});
});
describe("copy button", () => { describe("copy button", () => {
it("copy button writes message text and flashes copied", async () => { it("copy button writes message text and flashes copied", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -354,6 +704,7 @@ describe("copy button", () => {
toolCalls: [], toolCalls: [],
toolCallId: null, toolCallId: null,
streaming: false, streaming: false,
ts: 0,
}} }}
tools={new Map()} tools={new Map()}
/>, />,
@@ -384,6 +735,7 @@ describe("copy button", () => {
toolCalls: [], toolCalls: [],
toolCallId: null, toolCallId: null,
streaming: false, streaming: false,
ts: 0,
}} }}
tools={new Map()} tools={new Map()}
/>, />,
@@ -401,6 +753,7 @@ describe("copy button", () => {
toolCalls: [], toolCalls: [],
toolCallId: "c1", toolCallId: "c1",
streaming: false, streaming: false,
ts: 0,
}} }}
tools={new Map()} tools={new Map()}
/>, />,
+285 -9
View File
@@ -1,9 +1,142 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, 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;
const COPY_FEEDBACK_MS: number = 1200; const COPY_FEEDBACK_MS: number = 1200;
const PIN_THRESHOLD_PX: number = 80; const PIN_THRESHOLD_PX: number = 80;
const FAB_THRESHOLD_PX: number = 400;
const FENCE: string = "```";
const ENTER_KEY: string = "Enter";
const ESCAPE_KEY: string = "Escape";
const INLINE_RE: RegExp =
/(`[^`\n]+`)|(\[[^\]\n]+\]\([^)\s]+\))|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)/g;
const SAFE_URL_RE: RegExp = /^https?:\/\//i;
// ---------- safe mini-markdown (no deps, no innerHTML) ----------
function highlight(text: string, query: string): React.ReactNode {
if (query.length === 0 || text.length === 0) return text;
const hay: string = text.toLowerCase();
const needle: string = query.toLowerCase();
if (!hay.includes(needle)) return text;
const parts: React.ReactNode[] = [];
let from = 0;
let at: number = hay.indexOf(needle);
let n = 0;
while (at !== -1) {
if (at > from) parts.push(text.slice(from, at));
parts.push(
<mark className="hit" key={`h-${n}`}>
{text.slice(at, at + needle.length)}
</mark>,
);
n += 1;
from = at + needle.length;
at = hay.indexOf(needle, from);
}
if (from < text.length) parts.push(text.slice(from));
return parts;
}
function tokenNode(tok: string, query: string, key: string): React.ReactNode {
if (tok.startsWith("`")) return <code key={key}>{tok.slice(1, -1)}</code>;
if (tok.startsWith("**"))
return <strong key={key}>{highlight(tok.slice(2, -2), query)}</strong>;
if (tok.startsWith("*"))
return <em key={key}>{highlight(tok.slice(1, -1), query)}</em>;
// link token; INLINE_RE guarantees the [label](url) form
const closeAt: number = tok.indexOf("]");
const label: string = tok.slice(1, closeAt);
const url: string = tok.slice(closeAt + 2, -1);
if (SAFE_URL_RE.test(url))
return (
<a key={key} href={url} target="_blank" rel="noopener noreferrer">
{label}
</a>
);
// non-http scheme (javascript:, data:, …): the anchor is dropped, the
// raw token stays visible as plain text
return <span key={key}>{highlight(tok, query)}</span>;
}
function inlineNodes(
text: string,
query: string,
keyBase: string,
): React.ReactNode[] {
const out: React.ReactNode[] = [];
let from = 0;
let n = 0;
for (const m of text.matchAll(INLINE_RE)) {
const tok: string = m[0];
const at: number = m.index ?? 0;
if (at > from) out.push(highlight(text.slice(from, at), query));
out.push(tokenNode(tok, query, `${keyBase}-${n}`));
n += 1;
from = at + tok.length;
}
if (from < text.length) out.push(highlight(text.slice(from), query));
return out;
}
/** ```fences``` → <pre class="md-code">, `code`, **bold**, *italic*,
* [t](http…) links (http/https only). Plain segments keep the bubble's
* pre-wrap. Unterminated fences (streaming) render the tail as code. */
export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
const out: React.ReactNode[] = [];
let plain: string[] = [];
let code: string[] | null = null;
let n = 0;
const flushPlain = (): void => {
if (plain.length > 0) {
out.push(...inlineNodes(plain.join("\n"), query, `p-${n}`));
n += 1;
plain = [];
}
};
for (const line of text.split("\n")) {
if (line.trimStart().startsWith(FENCE)) {
if (code === null) {
flushPlain();
code = [];
} else {
out.push(
<pre className="md-code" key={`c-${n}`}>
{code.join("\n")}
</pre>,
);
n += 1;
code = null;
}
} else if (code !== null) {
code.push(line);
} else {
plain.push(line);
}
}
if (code !== null) {
out.push(
<pre className="md-code" key={`c-${n}`}>
{code.join("\n")}
</pre>,
);
} else {
flushPlain();
}
return out;
}
function formatTs(ts: number): string {
return new Date(ts).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
function searchHaystack(m: ChatMessage): string {
const names: string[] = m.toolCalls.map((c) => c.name);
return `${m.text} ${m.thinking ?? ""} ${names.join(" ")}`.toLowerCase();
}
function oneLine(text: string): string { function oneLine(text: string): string {
const flat = text.replace(/\s+/g, " ").trim(); const flat = text.replace(/\s+/g, " ").trim();
@@ -76,10 +209,22 @@ function Thinking({ text }: { text: string }) {
export function Bubble({ export function Bubble({
msg, msg,
tools, tools,
query = "",
showTs = false,
}: { }: {
msg: ChatMessage; msg: ChatMessage;
tools: Map<string, ToolState>; tools: Map<string, ToolState>;
/** live search query: matches in plain text get <mark class="hit"> */
query?: string;
showTs?: boolean;
}) { }) {
if (msg.notice === true) {
return (
<div className="bubble-row system">
<div className="notice-line"> {msg.text}</div>
</div>
);
}
const rowClass = const rowClass =
msg.role === "user" msg.role === "user"
? "user" ? "user"
@@ -95,8 +240,13 @@ export function Bubble({
} }
const copyable: boolean = const copyable: boolean =
msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0); msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0);
const tsTitle: string = msg.ts > 0 ? new Date(msg.ts).toISOString() : "";
return ( return (
<div className={`bubble-row ${rowClass}`}> <div
className={`bubble-row ${rowClass}`}
data-key={msg.key}
title={tsTitle.length > 0 ? tsTitle : undefined}
>
{copyable && <CopyButton text={msg.text} />} {copyable && <CopyButton text={msg.text} />}
<div className="bubble"> <div className="bubble">
{msg.thinking !== null && msg.thinking.length > 0 && ( {msg.thinking !== null && msg.thinking.length > 0 && (
@@ -114,10 +264,15 @@ export function Bubble({
<div className="tool-body">{msg.text}</div> <div className="tool-body">{msg.text}</div>
</details> </details>
) : ( ) : (
msg.text.length > 0 && <div>{msg.text}</div> msg.text.length > 0 && <div>{renderMarkdown(msg.text, query)}</div>
)} )}
{msg.streaming && <span className="stream-caret"></span>} {msg.streaming && <span className="stream-caret"></span>}
</div> </div>
{showTs && msg.ts > 0 && (
<div className="ts" title={tsTitle}>
{formatTs(msg.ts)}
</div>
)}
</div> </div>
); );
} }
@@ -142,6 +297,11 @@ interface Props {
hasOlder: boolean; hasOlder: boolean;
loadingOlder: boolean; loadingOlder: boolean;
onLoadOlder: () => void; onLoadOlder: () => void;
/** search overlay open (the 🔍 button or the / hotkey in ChatView) */
searchOpen: boolean;
onSearchClose: () => void;
/** per-bubble timestamps (⋯ menu in ChatView) */
showTs: boolean;
} }
export default function ChatStream({ export default function ChatStream({
@@ -151,20 +311,35 @@ export default function ChatStream({
hasOlder, hasOlder,
loadingOlder, loadingOlder,
onLoadOlder, onLoadOlder,
searchOpen,
onSearchClose,
showTs,
}: Props) { }: Props) {
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const pinnedRef = useRef<boolean>(true); const pinnedRef = useRef<boolean>(true);
const rowRefs = useRef<Map<string, HTMLElement>>(new Map());
const [query, setQuery] = useState<string>("");
const [matchIdx, setMatchIdx] = useState<number>(0);
const [showFab, setShowFab] = useState<boolean>(false);
const awayFromBottom = (): number => {
const el = scrollRef.current;
if (el === null) return 0;
return el.scrollHeight - el.scrollTop - el.clientHeight;
};
const onScroll = (): void => { const onScroll = (): void => {
const el = scrollRef.current; if (scrollRef.current === null) return;
if (el === null) return; const away: number = awayFromBottom();
pinnedRef.current = pinnedRef.current = away < PIN_THRESHOLD_PX;
el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX; setShowFab(away > FAB_THRESHOLD_PX);
}; };
useEffect(() => { useEffect(() => {
const el = scrollRef.current; const el = scrollRef.current;
if (el !== null && pinnedRef.current) el.scrollTop = el.scrollHeight; if (el === null) return;
if (pinnedRef.current) el.scrollTop = el.scrollHeight;
setShowFab(awayFromBottom() > FAB_THRESHOLD_PX);
}, [messages, busy]); }, [messages, busy]);
useEffect(() => { useEffect(() => {
@@ -173,11 +348,57 @@ export default function ChatStream({
if (el !== null) el.scrollTop = el.scrollHeight; if (el !== null) el.scrollTop = el.scrollHeight;
}, []); }, []);
// overlay closed → query and cursor reset (ChatStream is keyed per session,
// but the overlay must also forget a stale query when merely closed)
useEffect(() => {
if (!searchOpen) {
setQuery("");
setMatchIdx(0);
}
}, [searchOpen]);
const matches: string[] = useMemo(() => {
const q: string = query.trim().toLowerCase();
if (q.length === 0) return [];
return messages
.filter((m) => searchHaystack(m).includes(q))
.map((m) => m.key);
}, [messages, query]);
// live events can shrink the match set under the cursor
useEffect(() => {
setMatchIdx((i) =>
matches.length === 0 ? 0 : Math.min(i, matches.length - 1),
);
}, [matches.length]);
const cycleMatch = (dir: 1 | -1): void => {
if (matches.length === 0) return;
const len: number = matches.length;
const next: number = (((matchIdx + dir) % len) + len) % len;
setMatchIdx(next);
const key: string | undefined = matches[next];
if (key !== undefined)
rowRefs.current.get(key)?.scrollIntoView({ block: "center" });
};
const jumpToBottom = (): void => {
const el = scrollRef.current;
if (el === null) return;
pinnedRef.current = true;
el.scrollTop = el.scrollHeight;
setShowFab(false);
};
const currentKey: string | undefined =
query.length > 0 && matches.length > 0 ? matches[matchIdx] : undefined;
const last: ChatMessage | undefined = messages[messages.length - 1]; const last: ChatMessage | undefined = messages[messages.length - 1];
const streamingOpen: boolean = last !== undefined && last.streaming; const streamingOpen: boolean = last !== undefined && last.streaming;
const showTyping: boolean = busy && !streamingOpen; const showTyping: boolean = busy && !streamingOpen;
return ( return (
<div className="chat-stream">
<div className="chat-scroll" ref={scrollRef} onScroll={onScroll}> <div className="chat-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="chat-inner"> <div className="chat-inner">
{hasOlder && ( {hasOlder && (
@@ -194,10 +415,65 @@ export default function ChatStream({
</div> </div>
)} )}
{messages.map((m) => ( {messages.map((m) => (
<Bubble key={m.key} msg={m} tools={tools} /> <div
key={m.key}
className={m.key === currentKey ? "msg-current" : undefined}
ref={(el: HTMLDivElement | null): void => {
if (el === null) rowRefs.current.delete(m.key);
else rowRefs.current.set(m.key, el);
}}
>
<Bubble msg={m} tools={tools} query={query} showTs={showTs} />
</div>
))} ))}
{showTyping && <TypingIndicator />} {showTyping && <TypingIndicator />}
</div> </div>
</div> </div>
{searchOpen && (
<div className="search-bar">
<input
type="text"
autoFocus
value={query}
placeholder="Search…"
aria-label="Search"
onChange={(e) => {
setQuery(e.target.value);
setMatchIdx(0);
}}
onKeyDown={(e) => {
if (e.key === ESCAPE_KEY) onSearchClose();
else if (e.key === ENTER_KEY) {
e.preventDefault();
cycleMatch(e.shiftKey ? -1 : 1);
}
}}
/>
{query.length > 0 && (
<span className="search-count" aria-live="polite">
{matches.length === 0 ? 0 : matchIdx + 1}/{matches.length}
</span>
)}
<button
type="button"
className="icon-btn"
aria-label="Close search"
onClick={onSearchClose}
>
</button>
</div>
)}
{showFab && (
<button
type="button"
className="scroll-fab"
aria-label="Jump to latest"
onClick={jumpToBottom}
>
</button>
)}
</div>
); );
} }
+741 -11
View File
@@ -11,7 +11,7 @@ import { MemoryRouter, Route, Routes, useNavigate } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { EventFrame } from "./protocol"; import type { EventFrame } from "./protocol";
import { ApiError } from "./api"; import { ApiError } from "./api";
import ChatView from "./ChatView"; import ChatView, { groupCatalog, resetCatalogCache } from "./ChatView";
import type { SessionsStore } from "./store"; import type { SessionsStore } from "./store";
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup"; import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
import type { SessionListItem } from "./protocol"; import type { SessionListItem } from "./protocol";
@@ -163,6 +163,7 @@ beforeEach(() => {
currentSub = null; currentSub = null;
pushToast.mockClear(); pushToast.mockClear();
seedSettings(); seedSettings();
resetCatalogCache();
}); });
afterEach(() => { afterEach(() => {
@@ -917,6 +918,234 @@ describe("ChatView stale async guards (S1/S4) and send draft (S6)", () => {
}); });
}); });
describe("ChatView model picker", () => {
const CATALOG = [
{ provider: "zai-renaud", id: "glm-5.3", name: "GLM-5.3" },
{ provider: "zai-renaud", id: "glm-5.4", name: "GLM-5.4" },
{ provider: "anthropic", id: "claude-x", name: "Claude X" },
];
it("groupCatalog groups by provider preserving arrival order", () => {
const groups = groupCatalog(CATALOG);
expect(groups.map((g) => g.provider)).toEqual(["zai-renaud", "anthropic"]);
expect(groups[0]?.models.map((m) => m.id)).toEqual(["glm-5.3", "glm-5.4"]);
expect(groupCatalog([])).toEqual([]);
});
it("popover fetches catalog, groups it, and POSTs the picked model", async () => {
const fetchMock = mockFetchJson((url, init) => {
if (url.endsWith("/api/model-catalog")) return CATALOG;
if (init?.method === "POST" && url.endsWith("/api/sessions/s1/model"))
return { ok: true };
return [];
});
renderChat(makeStore());
await screen.findByText("worker");
// chip reflects current model before opening
expect(screen.getByText("glm-5.3")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Switch model" }));
expect(await screen.findByText("GLM-5.4")).toBeInTheDocument();
// provider grouping: both zai models before the anthropic group
const options = screen
.getAllByRole("button", { name: /^Switch to / })
.map((b) => b.textContent);
expect(options).toEqual(["GLM-5.3", "GLM-5.4", "Claude X"]);
await userEvent.click(
screen.getByRole("button", { name: "Switch to anthropic/claude-x" }),
);
await vi.waitFor(() => {
const call = fetchMock.mock.calls.find(
([u, i]) =>
(i as RequestInit | undefined)?.method === "POST" &&
String(u).endsWith("/api/sessions/s1/model"),
);
expect(call).toBeDefined();
expect((call?.[1] as RequestInit).body).toBe(
JSON.stringify({ provider: "anthropic", modelId: "claude-x" }),
);
});
// popover closes on success
expect(screen.queryByRole("button", { name: /^Switch to / })).toBeNull();
});
it("non-409 model switch failure toasts the error", async () => {
mockFetchJson((url, init) => {
if (url.endsWith("/api/model-catalog"))
return [{ provider: "zai-renaud", id: "glm-5.4", name: "GLM-5.4" }];
if (init?.method === "POST" && url.endsWith("/api/sessions/s1/model"))
return jsonResponse({ error: "boom" }, 500);
return [];
});
renderChat(makeStore());
await screen.findByText("worker");
await userEvent.click(screen.getByRole("button", { name: "Switch model" }));
await userEvent.click(
screen.getByRole("button", { name: "Switch to zai-renaud/glm-5.4" }),
);
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("boom"));
});
it("catalog fetch failure renders a notice in the popover", async () => {
mockFetchJson((url) =>
url.endsWith("/api/model-catalog")
? jsonResponse({ error: "no catalog" }, 500)
: [],
);
renderChat(makeStore());
await screen.findByText("worker");
await userEvent.click(screen.getByRole("button", { name: "Switch model" }));
expect(await screen.findByText("no catalog")).toBeInTheDocument();
// second open re-fetches (cache never populated on failure)
expect(screen.queryByRole("button", { name: /^Switch to / })).toBeNull();
});
it("409 on model switch toasts session offline and keeps popover open", async () => {
mockFetchJson((url, init) => {
if (url.endsWith("/api/model-catalog"))
return [{ provider: "zai-renaud", id: "glm-5.4", name: "GLM-5.4" }];
if (init?.method === "POST" && url.endsWith("/api/sessions/s1/model"))
return jsonResponse({ error: "session offline" }, 409);
return [];
});
renderChat(makeStore());
await screen.findByText("worker");
await userEvent.click(screen.getByRole("button", { name: "Switch model" }));
await userEvent.click(
screen.getByRole("button", { name: "Switch to zai-renaud/glm-5.4" }),
);
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("session offline"),
);
});
});
describe("ChatView rename", () => {
it("pencil opens inline rename; save PATCHes and refreshes", async () => {
const fetchMock = mockFetchJson((url, init) => {
if (init?.method === "PATCH" && url.endsWith("/api/sessions/s1"))
return { ok: true };
return [];
});
const refresh = vi.fn(async () => []);
renderChat(makeStore({ refresh }));
await screen.findByText("worker");
await userEvent.click(
screen.getByRole("button", { name: "Rename session" }),
);
const input = screen.getByLabelText("Session name") as HTMLInputElement;
expect(input.value).toBe("worker");
await userEvent.clear(input);
await userEvent.type(input, "renamed");
fireEvent.keyDown(input, { key: "Enter" });
await vi.waitFor(() => {
const call = fetchMock.mock.calls.find(
([u, i]) =>
(i as RequestInit | undefined)?.method === "PATCH" &&
String(u).endsWith("/api/sessions/s1"),
);
expect(call).toBeDefined();
expect((call?.[1] as RequestInit).body).toBe(
JSON.stringify({ name: "renamed" }),
);
});
await vi.waitFor(() => expect(refresh).toHaveBeenCalled());
// edit row closes after save
expect(screen.queryByLabelText("Session name")).toBeNull();
expect(
screen.getByRole("button", { name: "Rename session" }),
).toBeInTheDocument();
});
it("unnamed session renames from an empty draft; empty save is a no-op", async () => {
const fetchMock = mockFetchJson(() => []);
const unnamed = [{ ...sessions[0]!, name: null }];
renderChat(makeStore({ sessions: unnamed }));
await screen.findByRole("button", { name: "Rename session" });
await userEvent.click(
screen.getByRole("button", { name: "Rename session" }),
);
const input = screen.getByLabelText("Session name") as HTMLInputElement;
expect(input.value).toBe("");
await userEvent.click(
screen.getByRole("button", { name: "Save session name" }),
);
expect(
fetchMock.mock.calls.filter(
([, i]) => (i as RequestInit | undefined)?.method === "PATCH",
),
).toHaveLength(0);
// empty name keeps the edit row open
expect(screen.getByLabelText("Session name")).toBeInTheDocument();
});
it("rename failure toasts and keeps the edit row open", async () => {
mockFetchJson((url, init) => {
if (init?.method === "PATCH" && url.endsWith("/api/sessions/s1"))
return jsonResponse({ error: "rename denied" }, 500);
return [];
});
renderChat(makeStore());
await screen.findByText("worker");
await userEvent.click(
screen.getByRole("button", { name: "Rename session" }),
);
const input = screen.getByLabelText("Session name") as HTMLInputElement;
await userEvent.clear(input);
await userEvent.type(input, "x");
await userEvent.click(
screen.getByRole("button", { name: "Save session name" }),
);
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("rename denied"),
);
expect(screen.getByLabelText("Session name")).toBeInTheDocument();
});
it("cancel and Escape close the rename row without PATCHing", async () => {
const fetchMock = mockFetchJson(() => []);
renderChat(makeStore());
await screen.findByText("worker");
await userEvent.click(
screen.getByRole("button", { name: "Rename session" }),
);
const input = screen.getByLabelText("Session name");
await userEvent.type(input, "!");
await userEvent.click(
screen.getByRole("button", { name: "Cancel rename" }),
);
expect(screen.queryByLabelText("Session name")).toBeNull();
await userEvent.click(
screen.getByRole("button", { name: "Rename session" }),
);
fireEvent.keyDown(screen.getByLabelText("Session name"), { key: "Escape" });
expect(screen.queryByLabelText("Session name")).toBeNull();
// empty name never PATCHes; the edit row stays open
await userEvent.click(
screen.getByRole("button", { name: "Rename session" }),
);
const empty = screen.getByLabelText("Session name") as HTMLInputElement;
await userEvent.clear(empty);
fireEvent.keyDown(empty, { key: "Enter" });
expect(screen.getByLabelText("Session name")).toBeInTheDocument();
expect(
fetchMock.mock.calls.filter(
([, i]) => (i as RequestInit | undefined)?.method === "PATCH",
).length,
).toBe(0);
});
});
describe("ChatView unknown session", () => { describe("ChatView unknown session", () => {
it("unknown id falls back to id title and hides agent-only controls", async () => { it("unknown id falls back to id title and hides agent-only controls", async () => {
mockFetchJson((url) => { mockFetchJson((url) => {
@@ -930,28 +1159,299 @@ describe("ChatView unknown session", () => {
}); });
}); });
describe("ChatView history management menu", () => {
function mockHistoryWithDeletes(): ReturnType<typeof mockFetchJson> {
return mockFetchJson((url, init) => {
if (init?.method === "DELETE" && url.endsWith("/events"))
return { ok: true, deleted: 4 };
if (init?.method === "DELETE" && url.endsWith("/api/sessions/s1"))
return { ok: true };
if (url.includes("/events")) return historyEvents();
return [];
});
}
it("menu toggle opens and closes the ⋯ dropdown", async () => {
mockFetchJson((url) => (url.includes("/events") ? historyEvents() : []));
renderChat(makeStore());
await screen.findByText("hello there");
const toggle = screen.getByRole("button", { name: "Session menu" });
expect(toggle).toHaveAttribute("aria-haspopup", "menu");
expect(toggle).toHaveAttribute("aria-expanded", "false");
fireEvent.click(toggle);
expect(screen.getByRole("menu")).toBeInTheDocument();
expect(toggle).toHaveAttribute("aria-expanded", "true");
fireEvent.click(toggle);
expect(screen.queryByRole("menu")).toBeNull();
});
it("Escape closes the open menu", async () => {
mockFetchJson((url) => (url.includes("/events") ? historyEvents() : []));
renderChat(makeStore());
await screen.findByText("hello there");
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
expect(screen.getByRole("menu")).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
expect(screen.queryByRole("menu")).toBeNull();
});
it("clicking outside closes the open menu", async () => {
mockFetchJson((url) => (url.includes("/events") ? historyEvents() : []));
renderChat(makeStore());
await screen.findByText("hello there");
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
expect(screen.getByRole("menu")).toBeInTheDocument();
fireEvent.mouseDown(document.body);
expect(screen.queryByRole("menu")).toBeNull();
});
it("Clear history: confirm deletes events, clears local state, toasts", async () => {
const fetchMock = mockHistoryWithDeletes();
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
renderChat(makeStore());
await screen.findByText("hello there");
expect(screen.getByText("hi!")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
await userEvent.click(
screen.getByRole("menuitem", { name: "Clear history" }),
);
await vi.waitFor(() => expect(confirm).toHaveBeenCalled());
await vi.waitFor(() =>
expect(screen.queryByText("hello there")).toBeNull(),
);
expect(screen.queryByText("hi!")).toBeNull();
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("history cleared"),
);
const del = fetchMock.mock.calls.find(
(c) =>
(c[1] as RequestInit | undefined)?.method === "DELETE" &&
String(c[0]).endsWith("/events"),
);
expect(del?.[0]).toBe("http://srv/api/sessions/s1/events");
// action closes the menu
expect(screen.queryByRole("menu")).toBeNull();
});
it("Clear history: cancel keeps everything", async () => {
const fetchMock = mockHistoryWithDeletes();
vi.spyOn(window, "confirm").mockReturnValue(false);
renderChat(makeStore());
await screen.findByText("hello there");
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
await userEvent.click(
screen.getByRole("menuitem", { name: "Clear history" }),
);
await vi.waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
expect(
fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit | undefined)?.method === "DELETE",
),
).toHaveLength(0);
expect(screen.getByText("hello there")).toBeInTheDocument();
expect(pushToast).not.toHaveBeenCalled();
});
it("Clear history failure toasts the error", async () => {
mockFetchJson((url, init) => {
if (init?.method === "DELETE" && url.endsWith("/events"))
return jsonResponse({ error: "boom" }, 500);
if (url.includes("/events")) return historyEvents();
return [];
});
vi.spyOn(window, "confirm").mockReturnValue(true);
renderChat(makeStore());
await screen.findByText("hello there");
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
await userEvent.click(
screen.getByRole("menuitem", { name: "Clear history" }),
);
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("clear failed: boom"),
);
expect(screen.getByText("hello there")).toBeInTheDocument();
});
it("Delete session: confirm deletes, toasts and navigates home", async () => {
const fetchMock = mockHistoryWithDeletes();
vi.spyOn(window, "confirm").mockReturnValue(true);
renderChat(makeStore()); // "*" route renders OTHER, so / lands there
await screen.findByText("hello there");
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
await userEvent.click(
screen.getByRole("menuitem", { name: "Delete session" }),
);
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("deleted worker"),
);
const del = fetchMock.mock.calls.find(
(c) =>
(c[1] as RequestInit | undefined)?.method === "DELETE" &&
String(c[0]).endsWith("/api/sessions/s1"),
);
expect(del?.[0]).toBe("http://srv/api/sessions/s1");
expect(await screen.findByText("OTHER")).toBeInTheDocument();
});
it("Delete session: cancel stays on the page", async () => {
const fetchMock = mockHistoryWithDeletes();
vi.spyOn(window, "confirm").mockReturnValue(false);
renderChat(makeStore());
await screen.findByText("hello there");
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
await userEvent.click(
screen.getByRole("menuitem", { name: "Delete session" }),
);
await vi.waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
expect(
fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit | undefined)?.method === "DELETE",
),
).toHaveLength(0);
expect(screen.getByText("hello there")).toBeInTheDocument();
expect(screen.queryByText("OTHER")).toBeNull();
});
it("Delete session failure toasts the error", async () => {
mockFetchJson((url, init) => {
if (init?.method === "DELETE" && url.endsWith("/api/sessions/s1"))
return jsonResponse({ error: "nope" }, 500);
if (url.includes("/events")) return historyEvents();
return [];
});
vi.spyOn(window, "confirm").mockReturnValue(true);
renderChat(makeStore());
await screen.findByText("hello there");
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
await userEvent.click(
screen.getByRole("menuitem", { name: "Delete session" }),
);
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("delete failed: nope"),
);
expect(screen.getByText("hello there")).toBeInTheDocument();
expect(screen.queryByText("OTHER")).toBeNull();
});
it("unknown session confirms with the id and still clears", async () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
mockFetchJson((_url, init) => {
if (init?.method === "DELETE") return { ok: true, deleted: 0 };
return [];
});
renderChat(makeStore(), "/s/ghost");
await screen.findByRole("button", { name: "Session menu" });
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
await userEvent.click(
screen.getByRole("menuitem", { name: "Clear history" }),
);
await vi.waitFor(() =>
expect(confirm).toHaveBeenCalledWith("Clear all history for ghost?"),
);
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("history cleared"),
);
});
it("unknown session delete confirms and toasts with the id", async () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
mockFetchJson((_url, init) => {
if (init?.method === "DELETE") return { ok: true };
return [];
});
renderChat(makeStore(), "/s/ghost");
await screen.findByRole("button", { name: "Session menu" });
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
await userEvent.click(
screen.getByRole("menuitem", { name: "Delete session" }),
);
await vi.waitFor(() =>
expect(confirm).toHaveBeenCalledWith(
"Delete session ghost? This cannot be undone.",
),
);
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("deleted ghost"),
);
expect(await screen.findByText("OTHER")).toBeInTheDocument();
});
it("unnamed session confirms and toasts with the id", async () => {
const unnamed = [{ ...sessions[0]!, name: null }];
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
mockFetchJson((url, init) => {
if (init?.method === "DELETE") return { ok: true };
if (url.includes("/events")) return historyEvents();
return [];
});
renderChat(makeStore({ sessions: unnamed }));
await screen.findByText("hello there");
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
await userEvent.click(
screen.getByRole("menuitem", { name: "Clear history" }),
);
await vi.waitFor(() =>
expect(confirm).toHaveBeenCalledWith("Clear all history for s1?"),
);
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
await userEvent.click(
screen.getByRole("menuitem", { name: "Delete session" }),
);
await vi.waitFor(() =>
expect(confirm).toHaveBeenCalledWith(
"Delete session s1? This cannot be undone.",
),
);
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("deleted s1"),
);
});
});
describe("ChatView usage chip", () => { describe("ChatView usage chip", () => {
it("usage chip renders when agent_end carried usage", async () => { const statsBody = {
turns: 3,
inputTokens: 1200,
outputTokens: 340,
totalCost: 0.02,
};
it("usage chip renders server stats with compact tokens and raw tooltip", async () => {
mockFetchJson((url) => { mockFetchJson((url) => {
if (url === "http://srv/api/sessions/s1/stats") return statsBody;
if (url.startsWith("http://srv/api/sessions/s1/events")) { if (url.startsWith("http://srv/api/sessions/s1/events")) {
seq = 0; seq = 0;
return [ return historyEvents();
...historyEvents(),
ev("agent_end", {
usage: { inputTokens: 1200, outputTokens: 340, totalCost: 0.02 },
}),
];
} }
return []; return [];
}); });
renderChat(makeStore()); renderChat(makeStore());
await screen.findByText("hello there"); await screen.findByText("hello there");
expect(screen.getByText(/↑1,200 ↓340/)).toBeInTheDocument(); expect(
expect(screen.getByText(/\$0\.02/)).toBeInTheDocument(); screen.getByText(/↑1\.2K ↓340 · 3 turns · \$0\.02/),
).toBeInTheDocument();
expect(
screen.getByTitle("↑1,200 ↓340 · 3 turns · $0.02"),
).toBeInTheDocument();
}); });
it("usage chip hidden when no usage seen", async () => { it("usage chip hidden when stats are all zero", async () => {
mockFetchJson((url) => { mockFetchJson((url) => {
if (url === "http://srv/api/sessions/s1/stats")
return { turns: 0, inputTokens: 0, outputTokens: 0, totalCost: 0 };
if (url.startsWith("http://srv/api/sessions/s1/events")) if (url.startsWith("http://srv/api/sessions/s1/events"))
return historyEvents(); return historyEvents();
return []; return [];
@@ -960,4 +1460,234 @@ describe("ChatView usage chip", () => {
await screen.findByText("hello there"); await screen.findByText("hello there");
expect(screen.queryByText(/↑\d/)).toBeNull(); expect(screen.queryByText(/↑\d/)).toBeNull();
}); });
it("refetches stats when a run settles", async () => {
const fetchMock = mockFetchJson((url) => {
if (url === "http://srv/api/sessions/s1/stats") return statsBody;
if (url.startsWith("http://srv/api/sessions/s1/events"))
return historyEvents();
return [];
});
renderChat(makeStore());
await screen.findByText("hello there");
await waitFor(() => expect(currentSub).not.toBeNull());
const callsBefore: number = fetchMock.mock.calls.filter(([u]) =>
String(u).endsWith("/api/sessions/s1/stats"),
).length;
expect(callsBefore).toBeGreaterThanOrEqual(1);
push([ev("agent_settled")]);
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(([u]) =>
String(u).endsWith("/api/sessions/s1/stats"),
).length,
).toBe(callsBefore + 1),
);
});
});
describe("ChatView composer QoL", () => {
it("placeholder switches between idle and busy steer copy", async () => {
mockFetchJson(() => []);
renderChat(makeStore());
const ta = (await screen.findByLabelText("Message")) as HTMLTextAreaElement;
expect(ta).toHaveAttribute("placeholder", "Message (Enter to send)");
push([ev("agent_start")]);
expect(ta).toHaveAttribute("placeholder", "Steer the agent (queued)…");
});
it("ArrowUp in the empty composer recalls the last sent message", async () => {
const fetchMock = mockFetchJson((_url, init) =>
init?.method === "POST" ? { ok: true } : [],
);
renderChat(makeStore());
const ta = (await screen.findByLabelText("Message")) as HTMLTextAreaElement;
// nothing sent yet: ArrowUp does nothing
fireEvent.keyDown(ta, { key: "ArrowUp" });
expect(ta.value).toBe("");
await userEvent.type(ta, "hello there");
fireEvent.keyDown(ta, { key: "Enter" });
await vi.waitFor(() =>
expect(fetchMock.mock.calls.length).toBeGreaterThan(0),
);
await vi.waitFor(() => expect(ta.value).toBe(""));
fireEvent.keyDown(ta, { key: "ArrowUp" });
expect(ta.value).toBe("hello there");
// non-empty draft: ArrowUp is caret movement, not recall
await userEvent.type(ta, "!");
fireEvent.keyDown(ta, { key: "ArrowUp" });
expect(ta.value).toBe("hello there!");
});
it("recall history is capped at the last 20 sends", async () => {
const posts: string[] = [];
mockFetchJson((_url, init) => {
if (init?.method === "POST") {
posts.push(String((init as RequestInit).body));
return { ok: true };
}
return [];
});
renderChat(makeStore());
const ta = (await screen.findByLabelText("Message")) as HTMLTextAreaElement;
for (let i = 1; i <= 21; i += 1) {
await userEvent.type(ta, `m${i}`);
fireEvent.keyDown(ta, { key: "Enter" });
// eslint-disable-next-line no-await-in-loop
await vi.waitFor(() => expect(posts.length).toBe(i));
// eslint-disable-next-line no-await-in-loop
await vi.waitFor(() => expect(ta.value).toBe(""));
}
expect(posts).toHaveLength(21);
fireEvent.keyDown(ta, { key: "ArrowUp" });
expect(ta.value).toBe("m21");
});
it("ArrowUp recall does not leak across sessions", async () => {
mockFetchJson((_url, init) =>
init?.method === "POST" ? { ok: true } : [],
);
function Switcher(): React.ReactElement {
const nav = useNavigate();
return (
<>
<button
type="button"
aria-label="switch session"
onClick={() => nav("/s/ghost")}
>
switch
</button>
<Routes>
<Route
path="/s/:id"
element={<ChatView store={makeStore()} pushToast={pushToast} />}
/>
</Routes>
</>
);
}
render(
<MemoryRouter initialEntries={["/s/s1"]}>
<Switcher />
</MemoryRouter>,
);
const ta = (await screen.findByLabelText("Message")) as HTMLTextAreaElement;
await userEvent.type(ta, "secret for s1");
fireEvent.keyDown(ta, { key: "Enter" });
await vi.waitFor(() => expect(ta.value).toBe(""));
await userEvent.click(
screen.getByRole("button", { name: "switch session" }),
);
const ta2 = (await screen.findByLabelText(
"Message",
)) as HTMLTextAreaElement;
fireEvent.keyDown(ta2, { key: "ArrowUp" });
expect(ta2.value).toBe(""); // s1's sent history must not leak into ghost
});
});
describe("ChatView search overlay", () => {
it("the 🔍 button opens the overlay, focused; Esc closes it", async () => {
mockFetchJson((url) =>
url.startsWith("http://srv/api/sessions/s1/events")
? historyEvents()
: [],
);
renderChat(makeStore());
await screen.findByText("hello there");
expect(screen.queryByLabelText("Search")).toBeNull();
fireEvent.click(
screen.getByRole("button", { name: "Search in conversation" }),
);
const input = screen.getByLabelText("Search");
expect(input).toHaveFocus();
// live filter over loaded history
await userEvent.type(input, "hello");
expect(screen.getByText("1/1")).toBeInTheDocument();
expect(document.querySelectorAll("mark.hit").length).toBe(1);
fireEvent.keyDown(input, { key: "Escape" });
expect(screen.queryByLabelText("Search")).toBeNull();
expect(document.querySelectorAll("mark.hit").length).toBe(0);
});
it("'/' opens the overlay unless typing in an input", async () => {
mockFetchJson(() => []);
renderChat(makeStore());
const ta = (await screen.findByLabelText("Message")) as HTMLTextAreaElement;
// '/' typed into the composer stays a character
await userEvent.type(ta, "/");
expect(screen.queryByLabelText("Search")).toBeNull();
expect(ta.value).toBe("/");
await userEvent.clear(ta);
// with modifiers it is ignored
fireEvent.keyDown(window, { key: "/", ctrlKey: true });
expect(screen.queryByLabelText("Search")).toBeNull();
// bare '/' anywhere else opens the overlay
fireEvent.keyDown(window, { key: "/" });
expect(screen.getByLabelText("Search")).toBeInTheDocument();
});
});
describe("ChatView ⋯ menu and timestamps", () => {
function tsEvents(): EventFrame[] {
seq = 0;
return [
ev("message_end", {
ts: 1000,
message: {
role: "user",
id: "u1",
text: "hello there",
thinking: null,
toolCalls: [],
toolCallId: null,
},
}),
ev("agent_settled", { ts: 1500 }),
];
}
it("Show timestamps is off by default and toggles the .ts lines", async () => {
mockFetchJson((url) =>
url.startsWith("http://srv/api/sessions/s1/events") ? tsEvents() : [],
);
const { container } = renderChat(makeStore());
await screen.findByText("hello there");
expect(container.querySelectorAll(".ts")).toHaveLength(0);
// the timestamp is still reachable via the row title attr
expect(container.querySelector(".bubble-row")).toHaveAttribute(
"title",
new Date(1000).toISOString(),
);
const menu = screen.getByRole("button", { name: "Session menu" });
expect(menu).toHaveAttribute("aria-expanded", "false");
fireEvent.click(menu);
const item = screen.getByRole("menuitemcheckbox", {
name: /Show timestamps/,
});
expect(item).toHaveAttribute("aria-checked", "false");
fireEvent.click(item);
expect(item).toHaveAttribute("aria-checked", "true");
expect(container.querySelectorAll(".ts")).toHaveLength(1);
// click outside closes the menu; the choice persists
fireEvent.pointerDown(document.body);
expect(menu).toHaveAttribute("aria-expanded", "false");
expect(container.querySelectorAll(".ts")).toHaveLength(1);
});
}); });
+198 -13
View File
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import type { import type {
EventFrame, EventFrame,
ModelCatalogEntry, ModelCatalogEntry,
RenameBody, RenameBody,
SessionStats,
SetModelBody, SetModelBody,
} from "./protocol"; } from "./protocol";
import { Route } from "./protocol"; import { EventType, Route } from "./protocol";
import { ApiError, errMessage, fetchJson } from "./api"; import { ApiError, errMessage, fetchJson } from "./api";
import { import {
deriveChat, deriveChat,
@@ -15,7 +16,7 @@ import {
mergeEvents, mergeEvents,
} from "./derive"; } from "./derive";
import type { SessionsStore } from "./store"; import type { SessionsStore } from "./store";
import { classNames } from "./store"; import { classNames, formatTokens } from "./store";
import ChatStream from "./ChatStream"; import ChatStream from "./ChatStream";
import TaskPanel from "./TaskPanel"; import TaskPanel from "./TaskPanel";
@@ -24,6 +25,11 @@ const LATEST_QUERY: string = "latest=1";
const TEXTAREA_MAX_H: number = 200; const TEXTAREA_MAX_H: number = 200;
const SEND_KEY: string = "Enter"; const SEND_KEY: string = "Enter";
const ESCAPE_KEY: string = "Escape"; const ESCAPE_KEY: string = "Escape";
const ARROW_UP_KEY: string = "ArrowUp";
const SEARCH_HOTKEY: string = "/";
const SENT_HISTORY_MAX: number = 20;
const PLACEHOLDER_IDLE: string = "Message (Enter to send)";
const PLACEHOLDER_BUSY: string = "Steer the agent (queued)…";
/** Catalog cache shared across mounts and session switches: the model list /** Catalog cache shared across mounts and session switches: the model list
* is static for a daemon run, so it is fetched at most once per page load. */ * is static for a daemon run, so it is fetched at most once per page load. */
@@ -64,6 +70,7 @@ interface Props {
export default function ChatView({ store, pushToast }: Props) { export default function ChatView({ store, pushToast }: Props) {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const sessionId: string = id ?? ""; const sessionId: string = id ?? "";
const navigate = useNavigate();
const [events, setEvents] = useState<EventFrame[]>([]); const [events, setEvents] = useState<EventFrame[]>([]);
const [loadError, setLoadError] = useState<string>(""); const [loadError, setLoadError] = useState<string>("");
@@ -80,10 +87,17 @@ export default function ChatView({ store, pushToast }: Props) {
const [switching, setSwitching] = useState<boolean>(false); const [switching, setSwitching] = useState<boolean>(false);
const [renaming, setRenaming] = useState<boolean>(false); const [renaming, setRenaming] = useState<boolean>(false);
const [renameDraft, setRenameDraft] = useState<string>(""); const [renameDraft, setRenameDraft] = useState<string>("");
const [searchOpen, setSearchOpen] = useState<boolean>(false);
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [showTs, setShowTs] = useState<boolean>(false);
const [stats, setStats] = useState<SessionStats | null>(null);
const lastSeqRef = useRef<number>(0); const lastSeqRef = useRef<number>(0);
const loadedRef = useRef<boolean>(false); const loadedRef = useRef<boolean>(false);
const taRef = useRef<HTMLTextAreaElement | null>(null); const taRef = useRef<HTMLTextAreaElement | null>(null);
// last sent messages for ArrowUp recall (per session, in-memory)
const sentRef = useRef<string[]>([]);
const menuRef = useRef<HTMLDivElement | null>(null);
// guards async fetches against session switches (S1) // guards async fetches against session switches (S1)
const sessionIdRef = useRef<string>(sessionId); const sessionIdRef = useRef<string>(sessionId);
sessionIdRef.current = sessionId; sessionIdRef.current = sessionId;
@@ -102,7 +116,18 @@ export default function ChatView({ store, pushToast }: Props) {
} }
}, [sessionId, session, store, pushToast]); }, [sessionId, session, store, pushToast]);
const applyEvents = useCallback((incoming: EventFrame[]): void => { // server-side usage stats (counts every turn, not just the loaded window)
const refreshStats = useCallback((): void => {
if (sessionId.length === 0) return;
void fetchJson<SessionStats>(Route.SessionStats(sessionId))
.then((s) => {
if (sessionIdRef.current === sessionId) setStats(s);
})
.catch(() => undefined);
}, [sessionId]);
const applyEvents = useCallback(
(incoming: EventFrame[]): void => {
setEvents((prev) => { setEvents((prev) => {
const merged = mergeEvents(prev, incoming); const merged = mergeEvents(prev, incoming);
lastSeqRef.current = Math.max( lastSeqRef.current = Math.max(
@@ -111,7 +136,12 @@ export default function ChatView({ store, pushToast }: Props) {
); );
return merged; return merged;
}); });
}, []); // a finished run means usage changed server-side
if (incoming.some((e) => e.type === EventType.AgentSettled))
refreshStats();
},
[refreshStats],
);
// history load on mount / session switch: newest page, ascending (B1) // history load on mount / session switch: newest page, ascending (B1)
useEffect(() => { useEffect(() => {
@@ -120,11 +150,16 @@ export default function ChatView({ store, pushToast }: Props) {
setLoadError(""); setLoadError("");
setEvents([]); setEvents([]);
setHasOlder(false); setHasOlder(false);
setStats(null);
lastSeqRef.current = 0; lastSeqRef.current = 0;
// a draft (or stuck sending state) from the previous session must not // a draft (or stuck sending state) from the previous session must not
// leak into the new one // leak into the new one
setDraft(""); setDraft("");
setSending(false); setSending(false);
setSearchOpen(false);
setMenuOpen(false);
sentRef.current = [];
refreshStats();
let alive = true; let alive = true;
void (async () => { void (async () => {
try { try {
@@ -143,7 +178,7 @@ export default function ChatView({ store, pushToast }: Props) {
return () => { return () => {
alive = false; alive = false;
}; };
}, [sessionId, applyEvents]); }, [sessionId, applyEvents, refreshStats]);
// ws subscription: dep on store.subscribe (stable per manager) rather than // ws subscription: dep on store.subscribe (stable per manager) rather than
// the whole store object — every sessions-list change would otherwise // the whole store object — every sessions-list change would otherwise
@@ -208,11 +243,57 @@ export default function ChatView({ store, pushToast }: Props) {
useEffect(autosize, [draft, autosize]); useEffect(autosize, [draft, autosize]);
// '/' opens the search overlay unless the user is typing somewhere
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key !== SEARCH_HOTKEY || e.metaKey || e.ctrlKey || e.altKey) return;
const t = e.target;
if (
t instanceof HTMLElement &&
(t.tagName === "INPUT" ||
t.tagName === "TEXTAREA" ||
t.isContentEditable)
)
return;
e.preventDefault();
setSearchOpen(true);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// Esc / outside click close the ⋯ menu
useEffect(() => {
if (!menuOpen) return;
const onKey = (e: KeyboardEvent): void => {
if (e.key === ESCAPE_KEY) setMenuOpen(false);
};
const outside = (target: EventTarget | null): boolean =>
menuRef.current !== null && !menuRef.current.contains(target as Node);
const onDown = (e: MouseEvent): void => {
if (outside(e.target)) setMenuOpen(false);
};
const onPointer = (e: PointerEvent): void => {
if (outside(e.target)) setMenuOpen(false);
};
document.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onDown);
document.addEventListener("pointerdown", onPointer);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onDown);
document.removeEventListener("pointerdown", onPointer);
};
}, [menuOpen]);
const send = async (): Promise<void> => { const send = async (): Promise<void> => {
const text = draft.trim(); const text = draft.trim();
if (text.length === 0 || sending) return; if (text.length === 0 || sending) return;
setDraft(""); setDraft("");
setSending(true); setSending(true);
const hist = sentRef.current;
hist.push(text);
if (hist.length > SENT_HISTORY_MAX) hist.shift();
try { try {
await fetchJson(Route.SessionPrompt(sessionId), { await fetchJson(Route.SessionPrompt(sessionId), {
method: "POST", method: "POST",
@@ -296,12 +377,56 @@ export default function ChatView({ store, pushToast }: Props) {
} }
}; };
const clearHistory = async (): Promise<void> => {
setMenuOpen(false);
if (!window.confirm(`Clear all history for ${session?.name ?? sessionId}?`))
return;
try {
await fetchJson(Route.SessionEvents(sessionId), {
method: "DELETE",
});
setEvents([]);
lastSeqRef.current = 0;
setHasOlder(false);
pushToast("history cleared");
} catch (err) {
pushToast(`clear failed: ${errMessage(err)}`);
}
};
const deleteSessionAction = async (): Promise<void> => {
setMenuOpen(false);
if (
!window.confirm(
`Delete session ${session?.name ?? sessionId}? This cannot be undone.`,
)
)
return;
try {
await fetchJson(Route.Session(sessionId), { method: "DELETE" });
pushToast(`deleted ${session?.name ?? sessionId}`);
navigate("/");
} catch (err) {
pushToast(`delete failed: ${errMessage(err)}`);
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => { const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
// IME composition: Enter confirms the candidate window, not a send // IME composition: Enter confirms the candidate window, not a send
if (e.nativeEvent.isComposing) return; if (e.nativeEvent.isComposing) return;
if (e.key === SEND_KEY && !e.shiftKey) { if (e.key === SEND_KEY && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
void send(); void send();
return;
}
// empty composer + history: ArrowUp recalls the last sent message
if (e.key === ARROW_UP_KEY && draft.length === 0) {
const last: string | undefined =
sentRef.current[sentRef.current.length - 1];
if (last !== undefined) {
e.preventDefault();
setDraft(last);
}
} }
}; };
@@ -403,12 +528,15 @@ export default function ChatView({ store, pushToast }: Props) {
</div> </div>
)} )}
</div> </div>
{(chat.usage.inputTokens > 0 || chat.usage.outputTokens > 0) && ( {stats !== null &&
<span className="usage-chip" title="tokens this session"> (stats.inputTokens > 0 || stats.outputTokens > 0) && (
{chat.usage.inputTokens.toLocaleString()} <span
{chat.usage.outputTokens.toLocaleString()} className="usage-chip"
{chat.usage.totalCost > 0 && title={`${stats.inputTokens.toLocaleString()}${stats.outputTokens.toLocaleString()} · ${stats.turns} turns · $${stats.totalCost.toFixed(2)}`}
` · $${chat.usage.totalCost.toFixed(2)}`} >
{formatTokens(stats.inputTokens)}
{formatTokens(stats.outputTokens)} · {stats.turns} turns · $
{stats.totalCost.toFixed(2)}
</span> </span>
)} )}
<span <span
@@ -419,6 +547,60 @@ export default function ChatView({ store, pushToast }: Props) {
title={session?.online === true ? "online" : "offline"} title={session?.online === true ? "online" : "offline"}
/> />
<div className="spacer" /> <div className="spacer" />
<button
type="button"
className="icon-btn"
aria-label="Search in conversation"
aria-expanded={searchOpen}
onClick={() => setSearchOpen(true)}
>
🔍
</button>
<div className="menu-anchor" ref={menuRef}>
<button
type="button"
className="icon-btn"
aria-label="Session menu"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => setMenuOpen(!menuOpen)}
>
</button>
{menuOpen && (
<div
className="chat-menu"
role="menu"
aria-label="Session actions"
>
<button
type="button"
className="chat-menu-item"
role="menuitemcheckbox"
aria-checked={showTs}
onClick={() => setShowTs(!showTs)}
>
{showTs ? "✓" : " "} Show timestamps
</button>
<button
type="button"
role="menuitem"
className="chat-menu-item"
onClick={() => void clearHistory()}
>
Clear history
</button>
<button
type="button"
role="menuitem"
className="chat-menu-item danger"
onClick={() => void deleteSessionAction()}
>
Delete session
</button>
</div>
)}
</div>
{session?.agent === true && ( {session?.agent === true && (
<button <button
type="button" type="button"
@@ -458,6 +640,9 @@ export default function ChatView({ store, pushToast }: Props) {
hasOlder={hasOlder} hasOlder={hasOlder}
loadingOlder={loadingOlder} loadingOlder={loadingOlder}
onLoadOlder={() => void loadOlder()} onLoadOlder={() => void loadOlder()}
searchOpen={searchOpen}
onSearchClose={() => setSearchOpen(false)}
showTs={showTs}
/> />
)} )}
<div className="composer"> <div className="composer">
@@ -466,7 +651,7 @@ export default function ChatView({ store, pushToast }: Props) {
ref={taRef} ref={taRef}
rows={1} rows={1}
value={draft} value={draft}
placeholder="Message…" placeholder={busy ? PLACEHOLDER_BUSY : PLACEHOLDER_IDLE}
aria-label="Message" aria-label="Message"
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
+192 -2
View File
@@ -1,4 +1,10 @@
import { act, fireEvent, render, screen } from "@testing-library/react"; import {
act,
fireEvent,
render,
screen,
waitFor,
} 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";
@@ -144,6 +150,69 @@ describe("SessionsView", () => {
expect(screen.queryByLabelText(/Stop container/)).toBeNull(); expect(screen.queryByLabelText(/Stop container/)).toBeNull();
}); });
it("renders the global usage line from GET /api/stats", async () => {
seedSettings();
mockFetchJson((url) => {
if (url === "http://srv/api/stats")
return {
turns: 4210,
inputTokens: 1200000,
outputTokens: 340000,
totalCost: 4.2,
sessionsCount: 5,
onlineCount: 2,
};
return [];
});
renderView({ sessions: [session({ id: "s1", name: "live" })] });
expect(
await screen.findByText(
/4210 turns · 1\.2M↑ 340K↓ · \$4\.20 · 5 sessions/,
),
).toBeInTheDocument();
expect(screen.getByTitle("2 online")).toBeInTheDocument();
});
it("refetches global stats when the session list changes", async () => {
seedSettings();
const fetchMock = mockFetchJson((url) => {
if (url === "http://srv/api/stats")
return {
turns: 1,
inputTokens: 10,
outputTokens: 5,
totalCost: 0.01,
sessionsCount: 1,
onlineCount: 0,
};
return [];
});
const { rerender } = renderView({ sessions: [] });
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(([u]) => String(u).endsWith("/api/stats"))
.length,
).toBe(1),
);
// a WS session_list push replaces the array: the line must refresh
rerender(
<MemoryRouter>
<SessionsView
sessions={[session({ id: "s2", name: "new" })]}
onChanged={() => undefined}
pushToast={() => undefined}
/>
</MemoryRouter>,
);
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(([u]) => String(u).endsWith("/api/stats"))
.length,
).toBe(2),
);
});
it("keyboard Enter and Space open the session; other keys ignored", () => { it("keyboard Enter and Space open the session; other keys ignored", () => {
const probe = vi.fn(); const probe = vi.fn();
render( render(
@@ -264,7 +333,10 @@ describe("SessionsView", () => {
}); });
fireEvent.click(screen.getByLabelText("Stop container for worker")); fireEvent.click(screen.getByLabelText("Stop container for worker"));
await vi.waitFor(() => { await vi.waitFor(() => {
const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; const call = fetchMock.mock.calls.find(([u]) =>
String(u).endsWith("/api/sessions/s1/container"),
) as unknown as [string, RequestInit];
expect(call).toBeDefined();
expect(call[0]).toBe("http://srv/api/sessions/s1/container"); expect(call[0]).toBe("http://srv/api/sessions/s1/container");
expect(call[1].method).toBe("DELETE"); expect(call[1].method).toBe("DELETE");
}); });
@@ -306,3 +378,121 @@ describe("SessionsView", () => {
); );
}); });
}); });
describe("SessionsView delete session", () => {
it("trash button deletes the session, toasts and refreshes", async () => {
seedSettings();
const fetchMock = mockFetchJson(() => ({ ok: true }));
const onChanged = vi.fn();
const pushToast = vi.fn();
vi.spyOn(window, "confirm").mockReturnValue(true);
renderView({
sessions: [session({ id: "s1", name: "worker", agent: false })],
onChanged,
pushToast,
});
fireEvent.click(screen.getByLabelText("Delete session worker"));
await vi.waitFor(() => {
const call = fetchMock.mock.calls.find(([u]) =>
String(u).endsWith("/api/sessions/s1"),
) as unknown as [string, RequestInit];
expect(call).toBeDefined();
expect(call[0]).toBe("http://srv/api/sessions/s1");
expect(call[1].method).toBe("DELETE");
});
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("deleted worker"),
);
expect(onChanged).toHaveBeenCalled();
});
it("cancel keeps the session untouched", async () => {
seedSettings();
const fetchMock = mockFetchJson(() => ({ ok: true }));
const onChanged = vi.fn();
vi.spyOn(window, "confirm").mockReturnValue(false);
renderView({
sessions: [session({ id: "s1", name: "worker" })],
onChanged,
pushToast: () => undefined,
});
fireEvent.click(screen.getByLabelText("Delete session worker"));
expect(
fetchMock.mock.calls.filter(([u]) => !String(u).endsWith("/api/stats")),
).toHaveLength(0);
expect(onChanged).not.toHaveBeenCalled();
});
it("delete failure toasts the error", async () => {
seedSettings();
vi.spyOn(globalThis, "fetch").mockResolvedValue(
jsonResponse({ error: "nope" }, 500),
);
const pushToast = vi.fn();
vi.spyOn(window, "confirm").mockReturnValue(true);
renderView({
sessions: [session({ id: "s1", name: "w" })],
pushToast,
onChanged: () => undefined,
});
fireEvent.click(screen.getByLabelText("Delete session w"));
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("delete failed: nope"),
);
});
it("unnamed session confirms and toasts with the id", async () => {
seedSettings();
mockFetchJson(() => ({ ok: true }));
const pushToast = vi.fn();
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
renderView({
sessions: [session({ id: "s1", name: null })],
pushToast,
onChanged: () => undefined,
});
fireEvent.click(screen.getByLabelText("Delete session s1"));
expect(confirm).toHaveBeenCalledWith(
"Delete session s1? This cannot be undone.",
);
});
it("unnamed session delete success toasts the id", async () => {
seedSettings();
mockFetchJson(() => ({ ok: true }));
const pushToast = vi.fn();
vi.spyOn(window, "confirm").mockReturnValue(true);
renderView({
sessions: [session({ id: "s1", name: null })],
pushToast,
onChanged: () => undefined,
});
fireEvent.click(screen.getByLabelText("Delete session s1"));
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("deleted s1"),
);
});
it("non-error delete failure path stringifies non-Error throws", async () => {
seedSettings();
vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string");
const pushToast = vi.fn();
vi.spyOn(window, "confirm").mockReturnValue(true);
renderView({
sessions: [session({ id: "s1", name: "w" })],
pushToast,
onChanged: () => undefined,
});
fireEvent.click(screen.getByLabelText("Delete session w"));
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("delete failed: plain-string"),
);
});
it("archived sessions carry the trash button too", () => {
renderView({
sessions: [session({ id: "off", name: "dead", online: false })],
});
expect(screen.getByLabelText("Delete session dead")).toBeInTheDocument();
});
});
+56 -2
View File
@@ -1,9 +1,9 @@
import { type MouseEvent, useCallback, useEffect, useState } 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, StatsTotals } from "./protocol";
import { Route } from "./protocol"; import { Route } from "./protocol";
import { fetchJson } from "./api"; import { fetchJson } from "./api";
import { classNames, relativeTime } from "./store"; import { classNames, formatTokens, relativeTime } from "./store";
interface Props { interface Props {
sessions: SessionListItem[]; sessions: SessionListItem[];
@@ -15,10 +15,12 @@ function SessionCard({
s, s,
onOpen, onOpen,
onStop, onStop,
onDelete,
}: { }: {
s: SessionListItem; s: SessionListItem;
onOpen: (id: string) => void; onOpen: (id: string) => void;
onStop: (e: MouseEvent, s: SessionListItem) => void; onStop: (e: MouseEvent, s: SessionListItem) => void;
onDelete: (e: MouseEvent, s: SessionListItem) => void;
}): React.ReactNode { }): React.ReactNode {
return ( return (
<div <div
@@ -61,6 +63,14 @@ function SessionCard({
</button> </button>
)} )}
<button
type="button"
className="icon-btn danger"
aria-label={`Delete session ${s.name ?? s.id}`}
onClick={(e) => onDelete(e, s)}
>
🗑
</button>
</div> </div>
); );
} }
@@ -72,6 +82,21 @@ export default function SessionsView({
}: Props) { }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const [loaded, setLoaded] = useState<boolean>(false); const [loaded, setLoaded] = useState<boolean>(false);
const [stats, setStats] = useState<StatsTotals | null>(null);
// global usage line: fetched on mount and whenever the session list
// changes (each WS session_list push is a fresh array)
useEffect(() => {
let alive = true;
void fetchJson<StatsTotals>(Route.Stats)
.then((s) => {
if (alive) setStats(s);
})
.catch(() => undefined);
return (): void => {
alive = false;
};
}, [sessions]);
useEffect(() => { useEffect(() => {
// first refresh marks the list as loaded (skeletons until then) // first refresh marks the list as loaded (skeletons until then)
@@ -111,6 +136,26 @@ export default function SessionsView({
const active = sessions.filter((s) => s.online).sort(byActivity); const active = sessions.filter((s) => s.online).sort(byActivity);
const archived = sessions.filter((s) => !s.online).sort(byActivity); const archived = sessions.filter((s) => !s.online).sort(byActivity);
const remove = async (e: MouseEvent, s: SessionListItem): Promise<void> => {
e.preventDefault();
e.stopPropagation();
if (
!window.confirm(
`Delete session ${s.name ?? s.id}? This cannot be undone.`,
)
)
return;
try {
await fetchJson(Route.Session(s.id), { method: "DELETE" });
pushToast(`deleted ${s.name ?? s.id}`);
onChanged();
} catch (err) {
pushToast(
`delete failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
};
if (!loaded && sessions.length === 0) { if (!loaded && sessions.length === 0) {
return ( return (
<div className="page" aria-busy="true"> <div className="page" aria-busy="true">
@@ -131,6 +176,13 @@ export default function SessionsView({
<span className="count"> <span className="count">
{active.length} active · {archived.length} archived {active.length} active · {archived.length} archived
</span> </span>
{stats !== null && typeof stats.totalCost === "number" && (
<span className="count" title={`${stats.onlineCount} online`}>
{stats.turns} turns · {formatTokens(stats.inputTokens)}{" "}
{formatTokens(stats.outputTokens)} · ${stats.totalCost.toFixed(2)}{" "}
· {stats.sessionsCount} sessions
</span>
)}
</div> </div>
{active.length === 0 && ( {active.length === 0 && (
<p className="empty">No active sessions. Spawn one from the sidebar.</p> <p className="empty">No active sessions. Spawn one from the sidebar.</p>
@@ -141,6 +193,7 @@ export default function SessionsView({
s={s} s={s}
onOpen={open} onOpen={open}
onStop={(e, ss) => void stop(e, ss)} onStop={(e, ss) => void stop(e, ss)}
onDelete={(e, ss) => void remove(e, ss)}
/> />
))} ))}
{archived.length > 0 && ( {archived.length > 0 && (
@@ -154,6 +207,7 @@ export default function SessionsView({
s={s} s={s}
onOpen={open} onOpen={open}
onStop={(e, ss) => void stop(e, ss)} onStop={(e, ss) => void stop(e, ss)}
onDelete={(e, ss) => void remove(e, ss)}
/> />
))} ))}
</> </>
+174 -9
View File
@@ -1,7 +1,7 @@
import { act, fireEvent, render, screen } from "@testing-library/react"; import { act, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Repo, SessionListItem } from "./protocol"; import type { Repo, RepoImage, SessionListItem } from "./protocol";
import { Route as ApiRoute } from "./protocol"; import { Route as ApiRoute } from "./protocol";
import { fetchJson } from "./api"; import { fetchJson } from "./api";
import SpawnView from "./SpawnView"; import SpawnView from "./SpawnView";
@@ -249,7 +249,7 @@ describe("SpawnView spawn+poll", () => {
const fetchMock = mockFetchJson((url, init) => { const fetchMock = mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) { if (init?.method === "POST" && url.endsWith("/api/spawn")) {
posts.push([url, init]); posts.push([url, init]);
return { sessionId: "new-1", containerId: "abc123def456" }; return { sessionId: "new-1", imageUsed: "lvmh-worker:latest" };
} }
if (url.endsWith("/api/sessions")) if (url.endsWith("/api/sessions"))
return sessionsOnline return sessionsOnline
@@ -286,7 +286,7 @@ describe("SpawnView spawn+poll", () => {
JSON.stringify({ repo: "g/proj", branch: "main" }), JSON.stringify({ repo: "g/proj", branch: "main" }),
); );
expect(screen.getByText("Spawning…")).toBeInTheDocument(); expect(screen.getByText("Spawning…")).toBeInTheDocument();
expect(screen.getByText(/container abc123def456/)).toBeInTheDocument(); expect(screen.getByText(/image lvmh-worker:latest/)).toBeInTheDocument();
expect( expect(
screen.getByText("waiting for session to come online…"), screen.getByText("waiting for session to come online…"),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -317,7 +317,7 @@ describe("SpawnView spawn+poll", () => {
it("spawning view shows job line from status and spawn error text", async () => { it("spawning view shows job line from status and spawn error text", async () => {
mockFetchJson((url, init) => { mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sx", containerId: "" }; return { sessionId: "sx", imageUsed: "lvmh-worker:latest" };
if (url.endsWith("/api/gitlab/status")) if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" }; return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")]; if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
@@ -342,7 +342,7 @@ describe("SpawnView spawn+poll", () => {
mockFetchJson((url, init) => { mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) { if (init?.method === "POST" && url.endsWith("/api/spawn")) {
bodies.push(String(init.body)); bodies.push(String(init.body));
return { sessionId: "new-2", containerId: "cccccccccccc" }; return { sessionId: "new-2", imageUsed: "lvmh-worker:latest" };
} }
if (url.endsWith("/api/spawn/status")) return []; if (url.endsWith("/api/spawn/status")) return [];
if (url.endsWith("/api/gitlab/status")) if (url.endsWith("/api/gitlab/status"))
@@ -399,7 +399,7 @@ describe("SpawnView spawn+poll", () => {
return { connected: true, baseUrl: "https://gl", username: "alice" }; return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/slow")]; if (url.endsWith("/api/gitlab/repos")) return [repo("g/slow")];
if (url.endsWith("/api/spawn")) if (url.endsWith("/api/spawn"))
return { sessionId: "slow-1", containerId: "d" }; return { sessionId: "slow-1", imageUsed: "lvmh-worker:latest" };
return []; return [];
}); });
const { unmount } = render(tree(makeStore())); const { unmount } = render(tree(makeStore()));
@@ -426,7 +426,7 @@ describe("SpawnView spawn+poll", () => {
it("spawn job line renders from store.spawnJobs", async () => { it("spawn job line renders from store.spawnJobs", async () => {
mockFetchJson((url, init) => { mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sj-1", containerId: "cid" }; return { sessionId: "sj-1", imageUsed: "lvmh-worker:latest" };
if (url.endsWith("/api/spawn/status")) return []; if (url.endsWith("/api/spawn/status")) return [];
if (url.endsWith("/api/gitlab/status")) if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" }; return { connected: true, baseUrl: "https://gl", username: "alice" };
@@ -453,7 +453,7 @@ describe("SpawnSteps", () => {
it("renders progress steps matching job state", async () => { it("renders progress steps matching job state", async () => {
mockFetchJson((url, init) => { mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sp1", containerId: "" }; return { sessionId: "sp1", imageUsed: "lvmh-worker:latest" };
if (url.endsWith("/api/gitlab/status")) if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" }; return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")]; if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
@@ -479,7 +479,7 @@ describe("SpawnSteps", () => {
it("progress element carries progressbar semantics for the current step", async () => { it("progress element carries progressbar semantics for the current step", async () => {
mockFetchJson((url, init) => { mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sp2", containerId: "" }; return { sessionId: "sp2", imageUsed: "lvmh-worker:latest" };
if (url.endsWith("/api/gitlab/status")) if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" }; return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")]; if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
@@ -501,3 +501,168 @@ describe("SpawnSteps", () => {
expect(bar).toHaveAttribute("aria-valuenow", "2"); // building = step 2 expect(bar).toHaveAttribute("aria-valuenow", "2"); // building = step 2
}); });
}); });
describe("SpawnView repo images (registry)", () => {
const registry: RepoImage[] = [
{ repo: "g/alpha", image: "lvmh-worker-alpha", built: true },
{ repo: "g/beta", image: "lvmh-worker-beta", built: false },
];
function registryMock(prepareStatus = 200): ReturnType<typeof mockFetchJson> {
return mockFetchJson((url, init) => {
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos"))
return [repo("g/alpha"), repo("g/beta"), repo("g/plain")];
if (url.endsWith("/api/repos")) return registry;
if (url.endsWith("/api/repos/g/alpha/prepare") && init?.method === "POST")
return prepareStatus === 200
? { ok: true }
: jsonResponse({ error: "ops session offline" }, prepareStatus);
return [];
});
}
it("badges: green when built, amber needs-build when missing, tooltips carry the image", async () => {
registryMock();
render(tree(makeStore()));
await flush();
const ok = screen.getByText("custom image");
expect(ok.className).toContain("ok");
expect(ok).toHaveAttribute(
"title",
"custom image lvmh-worker-alpha — built",
);
const warn = screen.getByText("needs build");
expect(warn.className).toContain("warn");
expect(warn).toHaveAttribute(
"title",
"custom image lvmh-worker-beta not built — Prepare asks ops to build it",
);
// unregistered repos get no badge
const plain = screen.getByText("g/plain").closest(".repo-item");
expect(plain?.querySelector(".badge")).toBeNull();
});
it("prepare POSTs and toasts, without selecting the row", async () => {
const fetchMock = registryMock();
render(tree(makeStore()));
await flush();
fireEvent.click(
screen.getByRole("button", { name: "Prepare g/alpha via ops" }),
);
await flush();
const prepareCalls = fetchMock.mock.calls.filter(
([u, i]) =>
String(u).endsWith("/api/repos/g/alpha/prepare") &&
i?.method === "POST",
);
expect(prepareCalls).toHaveLength(1);
expect(PUSH_TOAST).toHaveBeenCalledWith(
"ops preparing g/alpha — watch lvmh-ops-control",
);
// clicking Prepare must not pick the row
expect(
screen
.getByText("g/alpha")
.closest(".repo-item")
?.getAttribute("aria-pressed"),
).toBe("false");
});
it("prepare Enter key does not select the row either (stopPropagation)", async () => {
const fetchMock = registryMock();
render(tree(makeStore()));
await flush();
const btn = screen.getByRole("button", { name: "Prepare g/beta via ops" });
fireEvent.keyDown(btn, { key: "Enter" });
await flush();
expect(
fetchMock.mock.calls.some(([u]) => String(u).endsWith("/prepare")),
).toBe(false); // jsdom synthesizes no click; only the guard ran
expect(
screen
.getByText("g/beta")
.closest(".repo-item")
?.getAttribute("aria-pressed"),
).toBe("false");
});
it("prepare failure toasts the daemon error", async () => {
registryMock(409);
render(tree(makeStore()));
await flush();
fireEvent.click(
screen.getByRole("button", { name: "Prepare g/alpha via ops" }),
);
await flush();
expect(PUSH_TOAST).toHaveBeenCalledWith("ops session offline");
});
it("registry fetch failure hides badges without breaking the picker", async () => {
mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha")];
if (url.endsWith("/api/repos"))
return jsonResponse({ error: "boom" }, 500);
return [];
});
render(tree(makeStore()));
await flush();
expect(screen.getByText("g/alpha")).toBeInTheDocument();
expect(document.querySelector(".badge")).toBeNull();
});
it("confirmation panel names the registered image or the base image", async () => {
registryMock();
render(tree(makeStore()));
await flush();
expect(screen.queryByText(/^Will use:/)).toBeNull();
fireEvent.click(screen.getByText("g/alpha"));
expect(screen.getByText("Will use: lvmh-worker-alpha")).toBeInTheDocument();
fireEvent.click(screen.getByText("g/plain"));
expect(screen.getByText("Will use: base worker image")).toBeInTheDocument();
});
it("job line appends the image message on running jobs", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sx", imageUsed: "lvmh-worker-alpha" };
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha")];
return [];
});
const store = makeStore({
spawnJobs: [
{
sessionId: "sx",
repo: "g/alpha",
state: "running",
containerId: "cid",
message: "image lvmh-worker-alpha",
},
],
});
render(tree(store));
await flush();
fireEvent.click(screen.getByText("g/alpha"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(
screen.getByText("g/alpha: running (image lvmh-worker-alpha)"),
).toBeInTheDocument();
});
});
+85 -9
View File
@@ -1,12 +1,20 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import type { GitlabStatus, Repo, SpawnJob, SpawnResponse } from "./protocol"; import type {
GitlabStatus,
Repo,
RepoImage,
SpawnJob,
SpawnResponse,
} from "./protocol";
import { Route } from "./protocol"; import { Route } from "./protocol";
import { errMessage, fetchJson } from "./api"; import { errMessage, fetchJson } from "./api";
import type { SessionsStore } from "./store"; import type { SessionsStore } from "./store";
const POLL_MS: number = 1500; const POLL_MS: number = 1500;
const POLL_MAX_TICKS: number = 400; // ~10min then give up polling (spawn may still finish) const POLL_MAX_TICKS: number = 400; // ~10min then give up polling (spawn may still finish)
// daemon ops control session (PROTOCOL: fixed id)
const OPS_SESSION_ID: string = "lvmh-ops-control";
interface Props { interface Props {
store: SessionsStore; store: SessionsStore;
@@ -47,6 +55,7 @@ export default function SpawnView({ store, pushToast }: Props) {
const [status, setStatus] = useState<GitlabStatus | null>(null); const [status, setStatus] = useState<GitlabStatus | null>(null);
const [pat, setPat] = useState<string>(""); const [pat, setPat] = useState<string>("");
const [repos, setRepos] = useState<Repo[] | null>(null); const [repos, setRepos] = useState<Repo[] | null>(null);
const [repoImages, setRepoImages] = useState<RepoImage[]>([]);
const [query, setQuery] = useState<string>(""); const [query, setQuery] = useState<string>("");
const [selected, setSelected] = useState<Repo | null>(null); const [selected, setSelected] = useState<Repo | null>(null);
const [branch, setBranch] = useState<string>(""); const [branch, setBranch] = useState<string>("");
@@ -74,11 +83,23 @@ export default function SpawnView({ store, pushToast }: Props) {
setRepos(list); setRepos(list);
}; };
// registry enrichment is cosmetic — a failed fetch just hides the badges
const loadRepoImages = async (): Promise<void> => {
try {
setRepoImages(await fetchJson<RepoImage[]>(Route.Repos));
} catch {
setRepoImages([]);
}
};
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
try { try {
const s = await loadStatus(); const s = await loadStatus();
if (s.connected) await loadRepos(); if (s.connected) {
await loadRepos();
void loadRepoImages();
}
} catch (err) { } catch (err) {
setError(errMessage(err)); setError(errMessage(err));
} }
@@ -101,6 +122,7 @@ export default function SpawnView({ store, pushToast }: Props) {
setPat(""); setPat("");
await loadStatus(); await loadStatus();
await loadRepos(); await loadRepos();
void loadRepoImages();
} catch (err) { } catch (err) {
setError(errMessage(err)); setError(errMessage(err));
} finally { } finally {
@@ -113,6 +135,19 @@ export default function SpawnView({ store, pushToast }: Props) {
setBranch(repo.defaultBranch); setBranch(repo.defaultBranch);
}; };
const registration = (path: string): RepoImage | undefined =>
repoImages.find((r) => r.repo === path);
// one-click prep: prompt the ops session, which works asynchronously
const prepare = async (repo: Repo): Promise<void> => {
try {
await fetchJson(Route.RepoPrepare(repo.path), { method: "POST" });
pushToast(`ops preparing ${repo.path} — watch ${OPS_SESSION_ID}`);
} catch (err) {
pushToast(errMessage(err));
}
};
const filtered: Repo[] = (repos ?? []).filter((r) => const filtered: Repo[] = (repos ?? []).filter((r) =>
r.path.toLowerCase().includes(query.toLowerCase()), r.path.toLowerCase().includes(query.toLowerCase()),
); );
@@ -179,7 +214,13 @@ export default function SpawnView({ store, pushToast }: Props) {
const job: SpawnJob | undefined = store.spawnJobs.find( const job: SpawnJob | undefined = store.spawnJobs.find(
(j) => j.sessionId === spawning.sessionId, (j) => j.sessionId === spawning.sessionId,
); );
if (job !== undefined) return `${job.repo}: ${job.state}`; if (job !== undefined) {
const detail =
job.message !== undefined && job.message.length > 0
? ` (${job.message})`
: "";
return `${job.repo}: ${job.state}${detail}`;
}
return "waiting for session to come online…"; return "waiting for session to come online…";
}; };
@@ -238,9 +279,7 @@ export default function SpawnView({ store, pushToast }: Props) {
<h2>Spawning</h2> <h2>Spawning</h2>
<SpawnSteps state={jobState()} /> <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">image {spawning.imageUsed}</p>
container {spawning.containerId.slice(0, 12)}
</p>
{error.length > 0 && <p className="error-text">{error}</p>} {error.length > 0 && <p className="error-text">{error}</p>}
</div> </div>
)} )}
@@ -261,7 +300,9 @@ export default function SpawnView({ store, pushToast }: Props) {
<p className="repo-meta">no matching repos</p> <p className="repo-meta">no matching repos</p>
)} )}
<div className="repo-list" style={{ marginTop: 10 }}> <div className="repo-list" style={{ marginTop: 10 }}>
{filtered.map((r) => ( {filtered.map((r) => {
const reg = registration(r.path);
return (
<div <div
key={r.path} key={r.path}
className={`repo-item ${selected?.path === r.path ? "selected" : ""}`} className={`repo-item ${selected?.path === r.path ? "selected" : ""}`}
@@ -277,15 +318,44 @@ export default function SpawnView({ store, pushToast }: Props) {
} }
}} }}
> >
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0, flex: 1 }}>
<div className="repo-path">{r.path}</div> <div className="repo-path">{r.path}</div>
<div className="repo-meta"> <div className="repo-meta">
default {r.defaultBranch} ·{" "} default {r.defaultBranch} ·{" "}
{r.lastActivityAt.slice(0, 10)} {r.lastActivityAt.slice(0, 10)}
</div> </div>
</div> </div>
{reg !== undefined && (
<span
className={reg.built ? "badge ok" : "badge warn"}
title={
reg.built
? `custom image ${reg.image} — built`
: `custom image ${reg.image} not built — Prepare asks ops to build it`
}
>
{reg.built ? "custom image" : "needs build"}
</span>
)}
<button
type="button"
className="prepare-btn"
aria-label={`Prepare ${r.path} via ops`}
onClick={(e) => {
e.stopPropagation();
void prepare(r);
}}
onKeyDown={(e) => {
// let the native button handle Enter/Space
if (e.key === "Enter" || e.key === " ")
e.stopPropagation();
}}
>
Prepare
</button>
</div> </div>
))} );
})}
</div> </div>
</div> </div>
@@ -315,6 +385,12 @@ export default function SpawnView({ store, pushToast }: Props) {
select a repo above select a repo above
</p> </p>
)} )}
{selected !== null && (
<p className="repo-meta" style={{ marginTop: 8 }}>
Will use:{" "}
{registration(selected.path)?.image ?? "base worker image"}
</p>
)}
{error.length > 0 && <p className="error-text">{error}</p>} {error.length > 0 && <p className="error-text">{error}</p>}
</div> </div>
</> </>
+53
View File
@@ -8,6 +8,28 @@ import {
mergeEvents, mergeEvents,
} from "./derive"; } from "./derive";
// ---------- error notices (slice 1) ----------
describe("error_notice derivation", () => {
it("becomes a system notice message with the reason as text", () => {
const chat = deriveChat([
ev({ type: "error_notice", reason: "model not found: zai/none" }),
]);
expect(chat.messages).toHaveLength(1);
const notice = chat.messages[0];
if (notice === undefined) throw new Error("missing notice");
expect(notice.notice).toBe(true);
expect(notice.role).toBe("system");
expect(notice.text).toBe("model not found: zai/none");
expect(notice.key).toMatch(/^notice-/);
});
it("falls back to a generic reason when the field is absent", () => {
const chat = deriveChat([ev({ type: "error_notice" })]);
expect(chat.messages[0]?.text).toBe("unknown error");
});
});
let seq: number = 0; let seq: number = 0;
function ev(partial: Partial<EventFrame> & { type: string }): EventFrame { function ev(partial: Partial<EventFrame> & { type: string }): EventFrame {
seq += 1; seq += 1;
@@ -180,6 +202,20 @@ describe("deriveChat", () => {
expect(messages.every((m) => m.streaming === false)).toBe(true); expect(messages.every((m) => m.streaming === false)).toBe(true);
}); });
it("messages carry the envelope ts; the streaming bubble carries 0", () => {
const chat = deriveChat([
ev({
type: "message_end",
ts: 1700000000000,
message: msg({ id: "u1", role: "user", text: "hi" }),
}),
ev({ type: "message_start", message: msg({ id: "a1" }) }),
ev({ type: "message_update", delta: "x", ts: 1700000000001 }),
]);
expect(chat.messages[0]?.ts).toBe(1700000000000);
expect(chat.messages[1]?.ts).toBe(0);
});
it("message_update deltas append into a streaming bubble ended by matching message_end", () => { it("message_update deltas append into a streaming bubble ended by matching message_end", () => {
const id = "a9"; const id = "a9";
const events = [ const events = [
@@ -300,6 +336,23 @@ describe("deriveChat", () => {
expect(chat.tools.size).toBe(0); expect(chat.tools.size).toBe(0);
}); });
it("todo ends without a parseable snapshot contribute nothing", () => {
const events: EventFrame[] = [
toolStart("t1", "todo", "[]"),
toolEnd("t1", false, "not json at all"),
ev({
type: "message_end",
message: msg({
id: "tr1",
role: "toolResult",
toolCallId: "t1",
text: "}{ garbage",
}),
}),
];
expect(deriveTasks(events).todos).toHaveLength(0);
});
it("tool execution defaults missing names/args", () => { it("tool execution defaults missing names/args", () => {
const chat = deriveChat([ const chat = deriveChat([
ev({ type: "tool_execution_start", toolCallId: "c" }), ev({ type: "tool_execution_start", toolCallId: "c" }),
+19
View File
@@ -40,6 +40,10 @@ export interface ChatMessage {
toolCalls: { id: string; name: string; argsJson: string }[]; toolCalls: { id: string; name: string; argsJson: string }[];
toolCallId: string | null; toolCallId: string | null;
streaming: boolean; streaming: boolean;
/** envelope ts of the persisted message_end (0 while streaming) */
ts: number;
/** true for daemon/plugin error notices: rendered as a notice line, not a chat bubble */
notice?: boolean;
} }
export interface ChatDerivation { export interface ChatDerivation {
@@ -128,10 +132,24 @@ export function deriveChat(events: EventFrame[]): ChatDerivation {
toolCalls: m.toolCalls ?? [], toolCalls: m.toolCalls ?? [],
toolCallId: m.toolCallId, toolCallId: m.toolCallId,
streaming: false, streaming: false,
ts: e.ts ?? 0,
}); });
} }
break; break;
} }
case "error_notice":
messages.push({
key: `notice-${e.seq}`,
role: "system",
text: e.reason ?? "unknown error",
thinking: null,
toolCalls: [],
toolCallId: null,
streaming: false,
ts: e.ts ?? 0,
notice: true,
});
break;
default: default:
break; break;
} }
@@ -146,6 +164,7 @@ export function deriveChat(events: EventFrame[]): ChatDerivation {
toolCalls: [], toolCalls: [],
toolCallId: null, toolCallId: null,
streaming: true, streaming: true,
ts: 0,
}); });
} }
+273
View File
@@ -396,6 +396,16 @@ a:hover {
color: var(--text-dim); color: var(--text-dim);
background: transparent; background: transparent;
} }
.badge.ok {
border-color: rgba(52, 211, 153, 0.4);
color: var(--ok);
background: rgba(52, 211, 153, 0.12);
}
.badge.warn {
border-color: rgba(251, 191, 36, 0.4);
color: var(--warn);
background: rgba(251, 191, 36, 0.12);
}
.skeleton { .skeleton {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@@ -465,6 +475,111 @@ a:hover {
white-space: nowrap; white-space: nowrap;
} }
/* model picker (header chip + popover) */
.model-picker {
position: relative;
}
.model-chip {
font-size: 12px;
font-family: var(--mono);
color: var(--text-faint);
background: transparent;
border: 1px solid var(--border);
padding: 2.5px 8px;
border-radius: 999px;
white-space: nowrap;
cursor: pointer;
}
.model-chip:hover {
color: var(--accent-strong);
border-color: var(--accent-line);
}
.model-popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 40;
min-width: 220px;
max-height: 60vh;
overflow-y: auto;
background: var(--bg-veil);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
}
.model-group {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-faint);
padding: 6px 6px 3px;
}
.model-option {
display: block;
width: 100%;
text-align: left;
font-size: 13px;
background: transparent;
border: none;
border-radius: 7px;
color: inherit;
padding: 6px 8px;
cursor: pointer;
}
.model-option:hover:not(:disabled) {
background: var(--accent-soft);
color: var(--accent-strong);
}
.model-option:disabled {
opacity: 0.5;
cursor: default;
}
.model-loading {
font-size: 12px;
color: var(--text-faint);
padding: 6px;
}
/* inline session rename */
.rename-row {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.rename-input {
font-size: 14px;
font-weight: 600;
color: inherit;
background: var(--bg-veil);
border: 1px solid var(--accent-line);
border-radius: 7px;
padding: 3px 8px;
min-width: 0;
max-width: 260px;
}
/* daemon/plugin error notices in the stream */
.notice-line {
font-size: 12px;
color: var(--text-faint);
background: rgba(255, 120, 120, 0.08);
border: 1px solid rgba(255, 120, 120, 0.25);
border-radius: 8px;
padding: 6px 10px;
margin: 4px 0;
white-space: pre-wrap;
word-break: break-word;
}
.chat-stream {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
position: relative;
}
.chat-scroll { .chat-scroll {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -625,6 +740,149 @@ details.thinking .thinking-body {
overflow-y: auto; overflow-y: auto;
} }
/* ---------- mini-markdown + search highlights ---------- */
.bubble .md-code {
white-space: pre;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
margin: 8px 0;
font-family: var(--mono);
font-size: 12.5px;
overflow-x: auto;
color: var(--text-dim);
}
.bubble code {
font-family: var(--mono);
font-size: 0.92em;
background: var(--bg-active);
border: 1px solid var(--border);
border-radius: 5px;
padding: 1px 5px;
}
mark.hit {
background: rgba(251, 191, 36, 0.35);
color: var(--text);
border-radius: 3px;
padding: 0 1px;
}
.msg-current mark.hit {
background: var(--warn);
color: #221a05;
}
/* ---------- search overlay ---------- */
.search-bar {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 15;
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-raised);
border: 1px solid var(--border-strong);
border-radius: 999px;
padding: 5px 7px 5px 14px;
box-shadow: var(--shadow-2);
}
.search-bar input {
border: none;
background: transparent;
padding: 2px 4px;
width: 240px;
max-width: 46vw;
}
.search-bar input:focus {
border: none;
box-shadow: none;
}
.search-count {
font-size: 12px;
color: var(--text-faint);
font-family: var(--mono);
white-space: nowrap;
}
/* ---------- scroll-to-bottom FAB ---------- */
.scroll-fab {
position: absolute;
right: 20px;
bottom: 18px;
width: 38px;
height: 38px;
border-radius: 50%;
background: var(--accent);
color: #fff;
font-weight: 700;
box-shadow: var(--shadow-2);
transition: background 0.15s ease;
}
.scroll-fab:hover {
background: var(--accent-strong);
}
/* ---------- timestamps ---------- */
.ts {
position: absolute;
bottom: -14px;
font-size: 10.5px;
color: var(--text-faint);
font-family: var(--mono);
}
.bubble-row.user .ts {
right: 0;
}
.bubble-row:not(.user) .ts {
left: 0;
}
/* ---------- header ⋯ menu ---------- */
.menu-anchor {
position: relative;
display: inline-flex;
}
.chat-menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 30;
min-width: 190px;
background: var(--bg-raised);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
box-shadow: var(--shadow-2);
padding: 6px;
}
.chat-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
text-align: left;
padding: 8px 10px;
border-radius: 8px;
color: var(--text-dim);
font-size: 13px;
}
.chat-menu-item:hover {
background: var(--bg-hover);
color: var(--text);
}
.chat-menu-item.danger {
color: var(--danger);
}
.chat-menu-item.danger:hover {
background: var(--danger-soft);
}
.typing { .typing {
display: inline-flex; display: inline-flex;
gap: 4px; gap: 4px;
@@ -984,6 +1242,21 @@ details.thinking .thinking-body {
color: var(--text-faint); color: var(--text-faint);
margin-top: 1px; margin-top: 1px;
} }
.repo-item .prepare-btn {
flex-shrink: 0;
margin-left: 8px;
font-size: 12px;
padding: 4px 10px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-dim);
cursor: pointer;
}
.repo-item .prepare-btn:hover {
border-color: var(--accent-line);
color: var(--text);
}
.spawn-job-line { .spawn-job-line {
font-size: 13px; font-size: 13px;
+9
View File
@@ -19,6 +19,7 @@ describe("protocol", () => {
expect(EventType.AgentEnd).toBe("agent_end"); expect(EventType.AgentEnd).toBe("agent_end");
expect(EventType.AgentSettled).toBe("agent_settled"); expect(EventType.AgentSettled).toBe("agent_settled");
expect(EventType.SessionInfo).toBe("session_info"); expect(EventType.SessionInfo).toBe("session_info");
expect(EventType.ErrorNotice).toBe("error_notice");
expect(EventType.Bye).toBe("bye"); expect(EventType.Bye).toBe("bye");
}); });
@@ -29,6 +30,9 @@ describe("protocol", () => {
expect(Route.GitlabStatus).toBe("/api/gitlab/status"); expect(Route.GitlabStatus).toBe("/api/gitlab/status");
expect(Route.GitlabConnect).toBe("/api/gitlab/connect"); expect(Route.GitlabConnect).toBe("/api/gitlab/connect");
expect(Route.GitlabRepos).toBe("/api/gitlab/repos"); expect(Route.GitlabRepos).toBe("/api/gitlab/repos");
expect(Route.ModelCatalog).toBe("/api/model-catalog");
expect(Route.Repos).toBe("/api/repos");
expect(Route.Stats).toBe("/api/stats");
}); });
it("session routes interpolate and encode ids", () => { it("session routes interpolate and encode ids", () => {
@@ -36,6 +40,11 @@ describe("protocol", () => {
expect(Route.SessionPrompt("abc")).toBe("/api/sessions/abc/prompt"); expect(Route.SessionPrompt("abc")).toBe("/api/sessions/abc/prompt");
expect(Route.SessionAbort("abc")).toBe("/api/sessions/abc/abort"); expect(Route.SessionAbort("abc")).toBe("/api/sessions/abc/abort");
expect(Route.SessionContainer("abc")).toBe("/api/sessions/abc/container"); expect(Route.SessionContainer("abc")).toBe("/api/sessions/abc/container");
expect(Route.Session("abc")).toBe("/api/sessions/abc");
expect(Route.SessionModel("abc")).toBe("/api/sessions/abc/model");
expect(Route.SessionStats("abc")).toBe("/api/sessions/abc/stats");
expect(Route.RepoImage("g/p")).toBe("/api/repos/g/p/image");
expect(Route.RepoPrepare("g/p")).toBe("/api/repos/g/p/prepare");
expect(Route.SessionEvents("a/b c")).toBe("/api/sessions/a%2Fb%20c/events"); expect(Route.SessionEvents("a/b c")).toBe("/api/sessions/a%2Fb%20c/events");
}); });
}); });
+62
View File
@@ -15,6 +15,7 @@ export const EventType = {
AgentEnd: "agent_end", AgentEnd: "agent_end",
AgentSettled: "agent_settled", AgentSettled: "agent_settled",
SessionInfo: "session_info", SessionInfo: "session_info",
ErrorNotice: "error_notice",
Bye: "bye", Bye: "bye",
} as const; } as const;
export type EventType = (typeof EventType)[keyof typeof EventType]; export type EventType = (typeof EventType)[keyof typeof EventType];
@@ -22,16 +23,26 @@ export type EventType = (typeof EventType)[keyof typeof EventType];
/** REST routes (base `/api`, bearer auth). */ /** REST routes (base `/api`, bearer auth). */
export const Route = { export const Route = {
Sessions: "/api/sessions", Sessions: "/api/sessions",
Session: (id: string): string => `/api/sessions/${encodeURIComponent(id)}`,
SessionEvents: (id: string): string => SessionEvents: (id: string): string =>
`/api/sessions/${encodeURIComponent(id)}/events`, `/api/sessions/${encodeURIComponent(id)}/events`,
SessionPrompt: (id: string): string => SessionPrompt: (id: string): string =>
`/api/sessions/${encodeURIComponent(id)}/prompt`, `/api/sessions/${encodeURIComponent(id)}/prompt`,
SessionAbort: (id: string): string => SessionAbort: (id: string): string =>
`/api/sessions/${encodeURIComponent(id)}/abort`, `/api/sessions/${encodeURIComponent(id)}/abort`,
SessionModel: (id: string): string =>
`/api/sessions/${encodeURIComponent(id)}/model`,
ModelCatalog: "/api/model-catalog",
SessionContainer: (id: string): string => SessionContainer: (id: string): string =>
`/api/sessions/${encodeURIComponent(id)}/container`, `/api/sessions/${encodeURIComponent(id)}/container`,
SessionStats: (id: string): string =>
`/api/sessions/${encodeURIComponent(id)}/stats`,
Stats: "/api/stats",
Spawn: "/api/spawn", Spawn: "/api/spawn",
SpawnStatus: "/api/spawn/status", SpawnStatus: "/api/spawn/status",
Repos: "/api/repos",
RepoImage: (repo: string): string => `/api/repos/${repo}/image`,
RepoPrepare: (repo: string): string => `/api/repos/${repo}/prepare`,
GitlabStatus: "/api/gitlab/status", GitlabStatus: "/api/gitlab/status",
GitlabConnect: "/api/gitlab/connect", GitlabConnect: "/api/gitlab/connect",
GitlabRepos: "/api/gitlab/repos", GitlabRepos: "/api/gitlab/repos",
@@ -114,6 +125,28 @@ export interface ContainerResponse {
ok: boolean; ok: boolean;
} }
export interface SetModelBody {
provider: string;
modelId: string;
}
export interface RenameBody {
name: string;
}
/** One selectable model from `GET /api/model-catalog`. */
export interface ModelCatalogEntry {
provider: string;
id: string;
name: string;
}
/** DELETE /api/sessions/:id/events response. */
export interface ClearHistoryResponse {
ok: boolean;
deleted: number;
}
export interface SpawnBody { export interface SpawnBody {
repo: string; repo: string;
branch?: string; branch?: string;
@@ -122,6 +155,8 @@ export interface SpawnBody {
export interface SpawnResponse { export interface SpawnResponse {
sessionId: string; sessionId: string;
containerId: string; containerId: string;
/** image the job will create the container from */
imageUsed: string;
} }
export interface SpawnJob { export interface SpawnJob {
@@ -129,6 +164,33 @@ export interface SpawnJob {
state: string; state: string;
containerId?: string; containerId?: string;
sessionId?: string; sessionId?: string;
/** running jobs: "image <ref>"; error jobs: the error text */
message?: string;
}
/** Per-session usage aggregation (`GET /api/sessions/:id/stats`). */
export interface SessionStats {
/** count of persisted agent_end events */
turns: number;
inputTokens: number;
outputTokens: number;
totalCost: number;
}
/** Daemon-wide usage totals (`GET /api/stats`). */
export interface StatsTotals extends SessionStats {
/** sessions with at least one agent_end event */
sessionsCount: number;
/** live agent WS connections */
onlineCount: number;
}
/** Repo → custom worker image registration row (GET /api/repos). */
export interface RepoImage {
repo: string;
image: string;
/** image present on the docker host (false → ops build needed) */
built: boolean;
} }
export interface GitlabStatus { export interface GitlabStatus {
+20 -1
View File
@@ -1,7 +1,13 @@
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getSettings, saveSettings } from "./settings"; import { getSettings, saveSettings } from "./settings";
import { classNames, relativeTime, useSessions, useToasts } from "./store"; import {
classNames,
formatTokens,
relativeTime,
useSessions,
useToasts,
} from "./store";
import { import {
FakeWebSocket, FakeWebSocket,
jsonResponse, jsonResponse,
@@ -39,6 +45,19 @@ describe("classNames", () => {
}); });
}); });
describe("formatTokens", () => {
it("compacts with one decimal under 100, no decimal above", () => {
expect(formatTokens(0)).toBe("0");
expect(formatTokens(999)).toBe("999");
expect(formatTokens(1000)).toBe("1K");
expect(formatTokens(1234)).toBe("1.2K");
expect(formatTokens(340000)).toBe("340K");
expect(formatTokens(1250000)).toBe("1.3M");
expect(formatTokens(1200000)).toBe("1.2M");
expect(formatTokens(2500000000)).toBe("2.5B");
});
});
describe("useToasts", () => { describe("useToasts", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
+21
View File
@@ -33,6 +33,27 @@ export function classNames(
return parts.filter(Boolean).join(" "); return parts.filter(Boolean).join(" ");
} }
const TOKEN_K: number = 1_000;
const TOKEN_M: number = 1_000_000;
const TOKEN_B: number = 1_000_000_000;
/** Compact token counts: 1234 → "1.2K", 1250000 → "1.3M", 340000 → "340K". */
export function formatTokens(n: number): string {
const unit: Array<{ size: number; suffix: string }> = [
{ size: TOKEN_B, suffix: "B" },
{ size: TOKEN_M, suffix: "M" },
{ size: TOKEN_K, suffix: "K" },
];
for (const { size, suffix } of unit) {
if (n >= size) {
const v: number = n / size;
const shown: number = v >= 100 ? Math.round(v) : Math.round(v * 10) / 10;
return `${shown}${suffix}`;
}
}
return String(n);
}
// ---------- toasts ---------- // ---------- toasts ----------
export interface Toast { export interface Toast {