diff --git a/web/.pi-merge-task.md b/web/.pi-merge-task.md deleted file mode 100644 index a873bf5..0000000 --- a/web/.pi-merge-task.md +++ /dev/null @@ -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/.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. diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 6e238ab..0617e49 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -60,6 +60,15 @@ const sessions: SessionListItem[] = [ function seedApi(): void { mockFetchJson((url) => { + if (url.endsWith("/stats")) + return { + turns: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0, + sessionsCount: 0, + onlineCount: 0, + }; if (url.includes("/api/sessions")) return url.includes("/events") ? [] : sessions; if (url.includes("/api/gitlab/status")) diff --git a/web/src/ChatStream.test.tsx b/web/src/ChatStream.test.tsx index 0522070..380772f 100644 --- a/web/src/ChatStream.test.tsx +++ b/web/src/ChatStream.test.tsx @@ -1,8 +1,12 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; 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 ChatStream, { Bubble, TypingIndicator } from "./ChatStream"; +import ChatStream, { + Bubble, + TypingIndicator, + renderMarkdown, +} from "./ChatStream"; function msg(partial: Partial): ChatMessage { return { @@ -13,6 +17,7 @@ function msg(partial: Partial): ChatMessage { toolCalls: [], toolCallId: null, streaming: false, + ts: 0, ...partial, }; } @@ -27,6 +32,31 @@ const tool = (p: Partial): ToolState => ({ ...p, }); +function stream(p: { + messages: ChatMessage[]; + busy: boolean; + hasOlder?: boolean; + loadingOlder?: boolean; + onOlder?: () => void; + searchOpen?: boolean; + onSearchClose?: () => void; + showTs?: boolean; +}): React.ReactElement { + return ( + undefined)} + searchOpen={p.searchOpen ?? false} + onSearchClose={p.onSearchClose ?? (() => undefined)} + showTs={p.showTs ?? false} + /> + ); +} + describe("Bubble", () => { it("renders plain text per role class", () => { const { container } = render( @@ -39,6 +69,18 @@ describe("Bubble", () => { expect(container.textContent).toContain("hi there"); }); + it("notice messages render as a ⚠ line, not a chat bubble", () => { + const { container } = render( + , + ); + 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", () => { const { container } = render( , @@ -195,25 +237,6 @@ describe("TypingIndicator", () => { }); describe("ChatStream", () => { - function stream(p: { - messages: ChatMessage[]; - busy: boolean; - hasOlder?: boolean; - loadingOlder?: boolean; - onOlder?: () => void; - }): React.ReactElement { - return ( - undefined)} - /> - ); - } - it("renders messages and typing indicator while busy with no open stream", () => { const { container, rerender } = render( 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( +
{renderMarkdown(text, query)}
, + ); + 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 ", () => { + 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( + , + ); + 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", () => { it("copy button writes message text and flashes copied", async () => { vi.useFakeTimers(); @@ -354,6 +704,7 @@ describe("copy button", () => { toolCalls: [], toolCallId: null, streaming: false, + ts: 0, }} tools={new Map()} />, @@ -384,6 +735,7 @@ describe("copy button", () => { toolCalls: [], toolCallId: null, streaming: false, + ts: 0, }} tools={new Map()} />, @@ -401,6 +753,7 @@ describe("copy button", () => { toolCalls: [], toolCallId: "c1", streaming: false, + ts: 0, }} tools={new Map()} />, diff --git a/web/src/ChatStream.tsx b/web/src/ChatStream.tsx index 1091643..6bee09c 100644 --- a/web/src/ChatStream.tsx +++ b/web/src/ChatStream.tsx @@ -1,9 +1,142 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { ChatMessage, ToolState } from "./derive"; const PREVIEW_LEN: number = 120; const COPY_FEEDBACK_MS: number = 1200; 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( + + {text.slice(at, at + needle.length)} + , + ); + 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 {tok.slice(1, -1)}; + if (tok.startsWith("**")) + return {highlight(tok.slice(2, -2), query)}; + if (tok.startsWith("*")) + return {highlight(tok.slice(1, -1), query)}; + // 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 ( + + {label} + + ); + // non-http scheme (javascript:, data:, …): the anchor is dropped, the + // raw token stays visible as plain text + return {highlight(tok, query)}; +} + +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``` →
, `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(
+					
+						{code.join("\n")}
+					
, + ); + n += 1; + code = null; + } + } else if (code !== null) { + code.push(line); + } else { + plain.push(line); + } + } + if (code !== null) { + out.push( +
+				{code.join("\n")}
+			
, + ); + } 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 { const flat = text.replace(/\s+/g, " ").trim(); @@ -76,10 +209,22 @@ function Thinking({ text }: { text: string }) { export function Bubble({ msg, tools, + query = "", + showTs = false, }: { msg: ChatMessage; tools: Map; + /** live search query: matches in plain text get */ + query?: string; + showTs?: boolean; }) { + if (msg.notice === true) { + return ( +
+
⚠ {msg.text}
+
+ ); + } const rowClass = msg.role === "user" ? "user" @@ -95,8 +240,13 @@ export function Bubble({ } const copyable: boolean = msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0); + const tsTitle: string = msg.ts > 0 ? new Date(msg.ts).toISOString() : ""; return ( -
+
0 ? tsTitle : undefined} + > {copyable && }
{msg.thinking !== null && msg.thinking.length > 0 && ( @@ -114,10 +264,15 @@ export function Bubble({
{msg.text}
) : ( - msg.text.length > 0 &&
{msg.text}
+ msg.text.length > 0 &&
{renderMarkdown(msg.text, query)}
)} {msg.streaming && }
+ {showTs && msg.ts > 0 && ( +
+ {formatTs(msg.ts)} +
+ )}
); } @@ -142,6 +297,11 @@ interface Props { hasOlder: boolean; loadingOlder: boolean; 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({ @@ -151,20 +311,35 @@ export default function ChatStream({ hasOlder, loadingOlder, onLoadOlder, + searchOpen, + onSearchClose, + showTs, }: Props) { const scrollRef = useRef(null); const pinnedRef = useRef(true); + const rowRefs = useRef>(new Map()); + const [query, setQuery] = useState(""); + const [matchIdx, setMatchIdx] = useState(0); + const [showFab, setShowFab] = useState(false); + + const awayFromBottom = (): number => { + const el = scrollRef.current; + if (el === null) return 0; + return el.scrollHeight - el.scrollTop - el.clientHeight; + }; const onScroll = (): void => { - const el = scrollRef.current; - if (el === null) return; - pinnedRef.current = - el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX; + if (scrollRef.current === null) return; + const away: number = awayFromBottom(); + pinnedRef.current = away < PIN_THRESHOLD_PX; + setShowFab(away > FAB_THRESHOLD_PX); }; useEffect(() => { 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]); useEffect(() => { @@ -173,31 +348,132 @@ export default function ChatStream({ 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 streamingOpen: boolean = last !== undefined && last.streaming; const showTyping: boolean = busy && !streamingOpen; return ( -
-
- {hasOlder && ( -
- +
+ )} + {messages.map((m) => ( +
{ + if (el === null) rowRefs.current.delete(m.key); + else rowRefs.current.set(m.key, el); + }} > - {loadingOlder ? "loading…" : "Load older"} - -
- )} - {messages.map((m) => ( - - ))} - {showTyping && } + +
+ ))} + {showTyping && } +
+ {searchOpen && ( +
+ { + 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 && ( + + {matches.length === 0 ? 0 : matchIdx + 1}/{matches.length} + + )} + +
+ )} + {showFab && ( + + )} ); } diff --git a/web/src/ChatView.test.tsx b/web/src/ChatView.test.tsx index f50318e..75ba474 100644 --- a/web/src/ChatView.test.tsx +++ b/web/src/ChatView.test.tsx @@ -11,7 +11,7 @@ import { MemoryRouter, Route, Routes, useNavigate } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { EventFrame } from "./protocol"; import { ApiError } from "./api"; -import ChatView from "./ChatView"; +import ChatView, { groupCatalog, resetCatalogCache } from "./ChatView"; import type { SessionsStore } from "./store"; import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup"; import type { SessionListItem } from "./protocol"; @@ -163,6 +163,7 @@ beforeEach(() => { currentSub = null; pushToast.mockClear(); seedSettings(); + resetCatalogCache(); }); 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", () => { it("unknown id falls back to id title and hides agent-only controls", async () => { mockFetchJson((url) => { @@ -930,28 +1159,299 @@ describe("ChatView unknown session", () => { }); }); +describe("ChatView history management menu", () => { + function mockHistoryWithDeletes(): ReturnType { + 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", () => { - 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) => { + if (url === "http://srv/api/sessions/s1/stats") return statsBody; if (url.startsWith("http://srv/api/sessions/s1/events")) { seq = 0; - return [ - ...historyEvents(), - ev("agent_end", { - usage: { inputTokens: 1200, outputTokens: 340, totalCost: 0.02 }, - }), - ]; + return historyEvents(); } return []; }); renderChat(makeStore()); await screen.findByText("hello there"); - expect(screen.getByText(/↑1,200 ↓340/)).toBeInTheDocument(); - expect(screen.getByText(/\$0\.02/)).toBeInTheDocument(); + expect( + 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) => { + 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")) return historyEvents(); return []; @@ -960,4 +1460,234 @@ describe("ChatView usage chip", () => { await screen.findByText("hello there"); 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 ( + <> + + + } + /> + + + ); + } + render( + + + , + ); + 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); + }); }); diff --git a/web/src/ChatView.tsx b/web/src/ChatView.tsx index e2c7b1c..7bf6ff4 100644 --- a/web/src/ChatView.tsx +++ b/web/src/ChatView.tsx @@ -1,12 +1,13 @@ 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 { EventFrame, ModelCatalogEntry, RenameBody, + SessionStats, SetModelBody, } from "./protocol"; -import { Route } from "./protocol"; +import { EventType, Route } from "./protocol"; import { ApiError, errMessage, fetchJson } from "./api"; import { deriveChat, @@ -15,7 +16,7 @@ import { mergeEvents, } from "./derive"; import type { SessionsStore } from "./store"; -import { classNames } from "./store"; +import { classNames, formatTokens } from "./store"; import ChatStream from "./ChatStream"; import TaskPanel from "./TaskPanel"; @@ -24,6 +25,11 @@ const LATEST_QUERY: string = "latest=1"; const TEXTAREA_MAX_H: number = 200; const SEND_KEY: string = "Enter"; 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 * 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) { const { id } = useParams<{ id: string }>(); const sessionId: string = id ?? ""; + const navigate = useNavigate(); const [events, setEvents] = useState([]); const [loadError, setLoadError] = useState(""); @@ -80,10 +87,17 @@ export default function ChatView({ store, pushToast }: Props) { const [switching, setSwitching] = useState(false); const [renaming, setRenaming] = useState(false); const [renameDraft, setRenameDraft] = useState(""); + const [searchOpen, setSearchOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const [showTs, setShowTs] = useState(false); + const [stats, setStats] = useState(null); const lastSeqRef = useRef(0); const loadedRef = useRef(false); const taRef = useRef(null); + // last sent messages for ArrowUp recall (per session, in-memory) + const sentRef = useRef([]); + const menuRef = useRef(null); // guards async fetches against session switches (S1) const sessionIdRef = useRef(sessionId); sessionIdRef.current = sessionId; @@ -102,16 +116,32 @@ export default function ChatView({ store, pushToast }: Props) { } }, [sessionId, session, store, pushToast]); - const applyEvents = useCallback((incoming: EventFrame[]): void => { - setEvents((prev) => { - const merged = mergeEvents(prev, incoming); - lastSeqRef.current = Math.max( - lastSeqRef.current, - lastPersistedSeq(merged), - ); - return merged; - }); - }, []); + // server-side usage stats (counts every turn, not just the loaded window) + const refreshStats = useCallback((): void => { + if (sessionId.length === 0) return; + void fetchJson(Route.SessionStats(sessionId)) + .then((s) => { + if (sessionIdRef.current === sessionId) setStats(s); + }) + .catch(() => undefined); + }, [sessionId]); + + const applyEvents = useCallback( + (incoming: EventFrame[]): void => { + setEvents((prev) => { + const merged = mergeEvents(prev, incoming); + lastSeqRef.current = Math.max( + lastSeqRef.current, + lastPersistedSeq(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) useEffect(() => { @@ -120,11 +150,16 @@ export default function ChatView({ store, pushToast }: Props) { setLoadError(""); setEvents([]); setHasOlder(false); + setStats(null); lastSeqRef.current = 0; // a draft (or stuck sending state) from the previous session must not // leak into the new one setDraft(""); setSending(false); + setSearchOpen(false); + setMenuOpen(false); + sentRef.current = []; + refreshStats(); let alive = true; void (async () => { try { @@ -143,7 +178,7 @@ export default function ChatView({ store, pushToast }: Props) { return () => { alive = false; }; - }, [sessionId, applyEvents]); + }, [sessionId, applyEvents, refreshStats]); // ws subscription: dep on store.subscribe (stable per manager) rather than // 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]); + // '/' 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 => { const text = draft.trim(); if (text.length === 0 || sending) return; setDraft(""); setSending(true); + const hist = sentRef.current; + hist.push(text); + if (hist.length > SENT_HISTORY_MAX) hist.shift(); try { await fetchJson(Route.SessionPrompt(sessionId), { method: "POST", @@ -296,12 +377,56 @@ export default function ChatView({ store, pushToast }: Props) { } }; + const clearHistory = async (): Promise => { + 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 => { + 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): void => { // IME composition: Enter confirms the candidate window, not a send if (e.nativeEvent.isComposing) return; if (e.key === SEND_KEY && !e.shiftKey) { e.preventDefault(); 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,14 +528,17 @@ export default function ChatView({ store, pushToast }: Props) { )} - {(chat.usage.inputTokens > 0 || chat.usage.outputTokens > 0) && ( - - ↑{chat.usage.inputTokens.toLocaleString()} ↓ - {chat.usage.outputTokens.toLocaleString()} - {chat.usage.totalCost > 0 && - ` · $${chat.usage.totalCost.toFixed(2)}`} - - )} + {stats !== null && + (stats.inputTokens > 0 || stats.outputTokens > 0) && ( + + ↑{formatTokens(stats.inputTokens)} ↓ + {formatTokens(stats.outputTokens)} · {stats.turns} turns · $ + {stats.totalCost.toFixed(2)} + + )}
+ +
+ + {menuOpen && ( +
+ + + +
+ )} +
{session?.agent === true && (
); } @@ -72,6 +82,21 @@ export default function SessionsView({ }: Props) { const navigate = useNavigate(); const [loaded, setLoaded] = useState(false); + const [stats, setStats] = useState(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(Route.Stats) + .then((s) => { + if (alive) setStats(s); + }) + .catch(() => undefined); + return (): void => { + alive = false; + }; + }, [sessions]); useEffect(() => { // 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 archived = sessions.filter((s) => !s.online).sort(byActivity); + const remove = async (e: MouseEvent, s: SessionListItem): Promise => { + 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) { return (
@@ -131,6 +176,13 @@ export default function SessionsView({ {active.length} active · {archived.length} archived + {stats !== null && typeof stats.totalCost === "number" && ( + + {stats.turns} turns · {formatTokens(stats.inputTokens)}↑{" "} + {formatTokens(stats.outputTokens)}↓ · ${stats.totalCost.toFixed(2)}{" "} + · {stats.sessionsCount} sessions + + )}
{active.length === 0 && (

No active sessions. Spawn one from the sidebar.

@@ -141,6 +193,7 @@ export default function SessionsView({ s={s} onOpen={open} onStop={(e, ss) => void stop(e, ss)} + onDelete={(e, ss) => void remove(e, ss)} /> ))} {archived.length > 0 && ( @@ -154,6 +207,7 @@ export default function SessionsView({ s={s} onOpen={open} onStop={(e, ss) => void stop(e, ss)} + onDelete={(e, ss) => void remove(e, ss)} /> ))} diff --git a/web/src/SpawnView.test.tsx b/web/src/SpawnView.test.tsx index a55151e..996b2e6 100644 --- a/web/src/SpawnView.test.tsx +++ b/web/src/SpawnView.test.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; 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 { fetchJson } from "./api"; import SpawnView from "./SpawnView"; @@ -249,7 +249,7 @@ describe("SpawnView spawn+poll", () => { const fetchMock = mockFetchJson((url, init) => { if (init?.method === "POST" && url.endsWith("/api/spawn")) { posts.push([url, init]); - return { sessionId: "new-1", containerId: "abc123def456" }; + return { sessionId: "new-1", imageUsed: "lvmh-worker:latest" }; } if (url.endsWith("/api/sessions")) return sessionsOnline @@ -286,7 +286,7 @@ describe("SpawnView spawn+poll", () => { JSON.stringify({ repo: "g/proj", branch: "main" }), ); expect(screen.getByText("Spawning…")).toBeInTheDocument(); - expect(screen.getByText(/container abc123def456/)).toBeInTheDocument(); + expect(screen.getByText(/image lvmh-worker:latest/)).toBeInTheDocument(); expect( screen.getByText("waiting for session to come online…"), ).toBeInTheDocument(); @@ -317,7 +317,7 @@ describe("SpawnView spawn+poll", () => { it("spawning view shows job line from status and spawn error text", async () => { mockFetchJson((url, init) => { 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")) return { connected: true, baseUrl: "https://gl", username: "alice" }; if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")]; @@ -342,7 +342,7 @@ describe("SpawnView spawn+poll", () => { mockFetchJson((url, init) => { if (init?.method === "POST" && url.endsWith("/api/spawn")) { 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/gitlab/status")) @@ -399,7 +399,7 @@ describe("SpawnView spawn+poll", () => { return { connected: true, baseUrl: "https://gl", username: "alice" }; if (url.endsWith("/api/gitlab/repos")) return [repo("g/slow")]; if (url.endsWith("/api/spawn")) - return { sessionId: "slow-1", containerId: "d" }; + return { sessionId: "slow-1", imageUsed: "lvmh-worker:latest" }; return []; }); const { unmount } = render(tree(makeStore())); @@ -426,7 +426,7 @@ describe("SpawnView spawn+poll", () => { it("spawn job line renders from store.spawnJobs", async () => { mockFetchJson((url, init) => { 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/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" }; @@ -453,7 +453,7 @@ describe("SpawnSteps", () => { it("renders progress steps matching job state", async () => { mockFetchJson((url, init) => { if (init?.method === "POST" && url.endsWith("/api/spawn")) - return { sessionId: "sp1", containerId: "" }; + return { sessionId: "sp1", imageUsed: "lvmh-worker:latest" }; if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "a" }; 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 () => { mockFetchJson((url, init) => { 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")) return { connected: true, baseUrl: "https://gl", username: "a" }; 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 }); }); + +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 { + 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(); + }); +}); diff --git a/web/src/SpawnView.tsx b/web/src/SpawnView.tsx index f75de4f..ec5f289 100644 --- a/web/src/SpawnView.tsx +++ b/web/src/SpawnView.tsx @@ -1,12 +1,20 @@ import { useEffect, useRef, useState } from "react"; 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 { errMessage, fetchJson } from "./api"; import type { SessionsStore } from "./store"; const POLL_MS: number = 1500; 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 { store: SessionsStore; @@ -47,6 +55,7 @@ export default function SpawnView({ store, pushToast }: Props) { const [status, setStatus] = useState(null); const [pat, setPat] = useState(""); const [repos, setRepos] = useState(null); + const [repoImages, setRepoImages] = useState([]); const [query, setQuery] = useState(""); const [selected, setSelected] = useState(null); const [branch, setBranch] = useState(""); @@ -74,11 +83,23 @@ export default function SpawnView({ store, pushToast }: Props) { setRepos(list); }; + // registry enrichment is cosmetic — a failed fetch just hides the badges + const loadRepoImages = async (): Promise => { + try { + setRepoImages(await fetchJson(Route.Repos)); + } catch { + setRepoImages([]); + } + }; + useEffect(() => { void (async () => { try { const s = await loadStatus(); - if (s.connected) await loadRepos(); + if (s.connected) { + await loadRepos(); + void loadRepoImages(); + } } catch (err) { setError(errMessage(err)); } @@ -101,6 +122,7 @@ export default function SpawnView({ store, pushToast }: Props) { setPat(""); await loadStatus(); await loadRepos(); + void loadRepoImages(); } catch (err) { setError(errMessage(err)); } finally { @@ -113,6 +135,19 @@ export default function SpawnView({ store, pushToast }: Props) { 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 => { + 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) => r.path.toLowerCase().includes(query.toLowerCase()), ); @@ -179,7 +214,13 @@ export default function SpawnView({ store, pushToast }: Props) { const job: SpawnJob | undefined = store.spawnJobs.find( (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…"; }; @@ -238,9 +279,7 @@ export default function SpawnView({ store, pushToast }: Props) {

Spawning…

{jobLine()}

-

- container {spawning.containerId.slice(0, 12)} -

+

image {spawning.imageUsed}

{error.length > 0 &&

{error}

} )} @@ -261,31 +300,62 @@ export default function SpawnView({ store, pushToast }: Props) {

no matching repos

)}
- {filtered.map((r) => ( -
pick(r)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - // role=button: keep Space from scrolling the page - e.preventDefault(); - pick(r); - } - }} - > -
-
{r.path}
-
- default {r.defaultBranch} ·{" "} - {r.lastActivityAt.slice(0, 10)} + {filtered.map((r) => { + const reg = registration(r.path); + return ( +
pick(r)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + // role=button: keep Space from scrolling the page + e.preventDefault(); + pick(r); + } + }} + > +
+
{r.path}
+
+ default {r.defaultBranch} ·{" "} + {r.lastActivityAt.slice(0, 10)} +
+ {reg !== undefined && ( + + {reg.built ? "custom image" : "needs build"} + + )} +
-
- ))} + ); + })}
@@ -315,6 +385,12 @@ export default function SpawnView({ store, pushToast }: Props) { select a repo above

)} + {selected !== null && ( +

+ Will use:{" "} + {registration(selected.path)?.image ?? "base worker image"} +

+ )} {error.length > 0 &&

{error}

}
diff --git a/web/src/derive.test.ts b/web/src/derive.test.ts index 09c93d8..26b12ae 100644 --- a/web/src/derive.test.ts +++ b/web/src/derive.test.ts @@ -8,6 +8,28 @@ import { mergeEvents, } 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; function ev(partial: Partial & { type: string }): EventFrame { seq += 1; @@ -180,6 +202,20 @@ describe("deriveChat", () => { 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", () => { const id = "a9"; const events = [ @@ -300,6 +336,23 @@ describe("deriveChat", () => { 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", () => { const chat = deriveChat([ ev({ type: "tool_execution_start", toolCallId: "c" }), diff --git a/web/src/derive.ts b/web/src/derive.ts index a77dab6..20e9ad2 100644 --- a/web/src/derive.ts +++ b/web/src/derive.ts @@ -40,6 +40,10 @@ export interface ChatMessage { toolCalls: { id: string; name: string; argsJson: string }[]; toolCallId: string | null; 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 { @@ -128,10 +132,24 @@ export function deriveChat(events: EventFrame[]): ChatDerivation { toolCalls: m.toolCalls ?? [], toolCallId: m.toolCallId, streaming: false, + ts: e.ts ?? 0, }); } 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: break; } @@ -146,6 +164,7 @@ export function deriveChat(events: EventFrame[]): ChatDerivation { toolCalls: [], toolCallId: null, streaming: true, + ts: 0, }); } diff --git a/web/src/index.css b/web/src/index.css index 1b290bb..93ddc19 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -396,6 +396,16 @@ a:hover { color: var(--text-dim); 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 { border-radius: var(--radius-lg); @@ -465,6 +475,111 @@ a:hover { 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 { flex: 1; overflow-y: auto; @@ -625,6 +740,149 @@ details.thinking .thinking-body { 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 { display: inline-flex; gap: 4px; @@ -984,6 +1242,21 @@ details.thinking .thinking-body { color: var(--text-faint); 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 { font-size: 13px; diff --git a/web/src/protocol.test.ts b/web/src/protocol.test.ts index 7c11ae4..aa4d61a 100644 --- a/web/src/protocol.test.ts +++ b/web/src/protocol.test.ts @@ -19,6 +19,7 @@ describe("protocol", () => { expect(EventType.AgentEnd).toBe("agent_end"); expect(EventType.AgentSettled).toBe("agent_settled"); expect(EventType.SessionInfo).toBe("session_info"); + expect(EventType.ErrorNotice).toBe("error_notice"); expect(EventType.Bye).toBe("bye"); }); @@ -29,6 +30,9 @@ describe("protocol", () => { expect(Route.GitlabStatus).toBe("/api/gitlab/status"); expect(Route.GitlabConnect).toBe("/api/gitlab/connect"); 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", () => { @@ -36,6 +40,11 @@ describe("protocol", () => { expect(Route.SessionPrompt("abc")).toBe("/api/sessions/abc/prompt"); expect(Route.SessionAbort("abc")).toBe("/api/sessions/abc/abort"); 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"); }); }); diff --git a/web/src/protocol.ts b/web/src/protocol.ts index 6401fe7..af84e5c 100644 --- a/web/src/protocol.ts +++ b/web/src/protocol.ts @@ -15,6 +15,7 @@ export const EventType = { AgentEnd: "agent_end", AgentSettled: "agent_settled", SessionInfo: "session_info", + ErrorNotice: "error_notice", Bye: "bye", } as const; 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). */ export const Route = { Sessions: "/api/sessions", + Session: (id: string): string => `/api/sessions/${encodeURIComponent(id)}`, SessionEvents: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/events`, SessionPrompt: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/prompt`, SessionAbort: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/abort`, + SessionModel: (id: string): string => + `/api/sessions/${encodeURIComponent(id)}/model`, + ModelCatalog: "/api/model-catalog", SessionContainer: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/container`, + SessionStats: (id: string): string => + `/api/sessions/${encodeURIComponent(id)}/stats`, + Stats: "/api/stats", Spawn: "/api/spawn", 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", GitlabConnect: "/api/gitlab/connect", GitlabRepos: "/api/gitlab/repos", @@ -114,6 +125,28 @@ export interface ContainerResponse { 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 { repo: string; branch?: string; @@ -122,6 +155,8 @@ export interface SpawnBody { export interface SpawnResponse { sessionId: string; containerId: string; + /** image the job will create the container from */ + imageUsed: string; } export interface SpawnJob { @@ -129,6 +164,33 @@ export interface SpawnJob { state: string; containerId?: string; sessionId?: string; + /** running jobs: "image "; 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 { diff --git a/web/src/store.test.tsx b/web/src/store.test.tsx index cb6457d..95306f0 100644 --- a/web/src/store.test.tsx +++ b/web/src/store.test.tsx @@ -1,7 +1,13 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getSettings, saveSettings } from "./settings"; -import { classNames, relativeTime, useSessions, useToasts } from "./store"; +import { + classNames, + formatTokens, + relativeTime, + useSessions, + useToasts, +} from "./store"; import { FakeWebSocket, 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", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/web/src/store.ts b/web/src/store.ts index 8ca0f71..ffc84a5 100644 --- a/web/src/store.ts +++ b/web/src/store.ts @@ -33,6 +33,27 @@ export function classNames( 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 ---------- export interface Toast {