web: integrate QoL slices 1-5 (model switch/rename, history deletes, usage stats, repo prepare+badges+imageUsed, markdown/search/FAB/timestamps) — 263 tests, ≥95% all metrics
This commit is contained in:
@@ -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"))
|
||||
|
||||
+374
-21
@@ -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>): ChatMessage {
|
||||
return {
|
||||
@@ -13,6 +17,7 @@ function msg(partial: Partial<ChatMessage>): ChatMessage {
|
||||
toolCalls: [],
|
||||
toolCallId: null,
|
||||
streaming: false,
|
||||
ts: 0,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
@@ -27,6 +32,31 @@ const tool = (p: Partial<ToolState>): ToolState => ({
|
||||
...p,
|
||||
});
|
||||
|
||||
function stream(p: {
|
||||
messages: ChatMessage[];
|
||||
busy: boolean;
|
||||
hasOlder?: boolean;
|
||||
loadingOlder?: boolean;
|
||||
onOlder?: () => void;
|
||||
searchOpen?: boolean;
|
||||
onSearchClose?: () => void;
|
||||
showTs?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<ChatStream
|
||||
messages={p.messages}
|
||||
tools={new Map()}
|
||||
busy={p.busy}
|
||||
hasOlder={p.hasOlder ?? false}
|
||||
loadingOlder={p.loadingOlder ?? false}
|
||||
onLoadOlder={p.onOlder ?? (() => undefined)}
|
||||
searchOpen={p.searchOpen ?? false}
|
||||
onSearchClose={p.onSearchClose ?? (() => undefined)}
|
||||
showTs={p.showTs ?? false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Bubble", () => {
|
||||
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(
|
||||
<Bubble
|
||||
msg={msg({ role: "system", text: "model not found", notice: true })}
|
||||
tools={new Map()}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector(".notice-line")).not.toBeNull();
|
||||
expect(container.textContent).toContain("⚠ model not found");
|
||||
expect(container.querySelector(".bubble")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders empty assistant text as nothing but shows nothing when empty", () => {
|
||||
const { container } = render(
|
||||
<Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />,
|
||||
@@ -195,25 +237,6 @@ describe("TypingIndicator", () => {
|
||||
});
|
||||
|
||||
describe("ChatStream", () => {
|
||||
function stream(p: {
|
||||
messages: ChatMessage[];
|
||||
busy: boolean;
|
||||
hasOlder?: boolean;
|
||||
loadingOlder?: boolean;
|
||||
onOlder?: () => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<ChatStream
|
||||
messages={p.messages}
|
||||
tools={new Map()}
|
||||
busy={p.busy}
|
||||
hasOlder={p.hasOlder ?? false}
|
||||
loadingOlder={p.loadingOlder ?? false}
|
||||
onLoadOlder={p.onOlder ?? (() => undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
it("renders messages and typing indicator while busy with no open stream", () => {
|
||||
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(
|
||||
<div data-testid="md">{renderMarkdown(text, query)}</div>,
|
||||
);
|
||||
return container.querySelector('[data-testid="md"]') as HTMLElement;
|
||||
};
|
||||
|
||||
it("plain text passes through unchanged with no element wrappers", () => {
|
||||
const el = md("hello world");
|
||||
expect(el.textContent).toBe("hello world");
|
||||
expect(el.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("``` fences render as pre.md-code with the fenced body", () => {
|
||||
const el = md("before\n```js\nlet a = 1\n```\nafter");
|
||||
const pre = el.querySelector("pre.md-code");
|
||||
expect(pre).not.toBeNull();
|
||||
expect(pre?.textContent).toBe("let a = 1");
|
||||
expect(el.textContent).toContain("before");
|
||||
expect(el.textContent).toContain("after");
|
||||
expect(el.querySelectorAll("pre")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("an unterminated fence (streaming) renders the tail as code", () => {
|
||||
const el = md("head\n```\ncode tail");
|
||||
const pre = el.querySelector("pre.md-code");
|
||||
expect(pre?.textContent).toBe("code tail");
|
||||
expect(el.textContent).toContain("head");
|
||||
});
|
||||
|
||||
it("`inline` code renders as <code>", () => {
|
||||
const el = md("use `npm run` now");
|
||||
expect(el.querySelector("code")?.textContent).toBe("npm run");
|
||||
expect(el.textContent).toBe("use npm run now");
|
||||
});
|
||||
|
||||
it("**bold** and *italic*", () => {
|
||||
const el = md("**b** and *i*");
|
||||
expect(el.querySelector("strong")?.textContent).toBe("b");
|
||||
expect(el.querySelector("em")?.textContent).toBe("i");
|
||||
// leading and trailing plain segments (empty ones are skipped)
|
||||
const edges = md("**b**");
|
||||
expect(edges.children).toHaveLength(1);
|
||||
expect(edges.textContent).toBe("b");
|
||||
});
|
||||
|
||||
it("[t](http…) links open in a new tab with noopener", () => {
|
||||
const el = md("see [docs](https://x.dev/a?b=1) and [m](http://y.org)");
|
||||
const links = el.querySelectorAll("a");
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0]).toHaveAttribute("href", "https://x.dev/a?b=1");
|
||||
expect(links[0]).toHaveAttribute("target", "_blank");
|
||||
expect(links[0]?.getAttribute("rel")).toContain("noopener");
|
||||
expect(links[0]?.textContent).toBe("docs");
|
||||
});
|
||||
|
||||
it("javascript: (and other non-http) links are dropped, text kept", () => {
|
||||
const el = md("[x](javascript:alert(1)) and [y](data:text/plain,hi)");
|
||||
expect(el.querySelector("a")).toBeNull();
|
||||
expect(el.textContent).toContain("[x](javascript:alert(1))");
|
||||
expect(el.textContent).toContain("[y](data:text/plain,hi)");
|
||||
});
|
||||
|
||||
it("query highlights match as mark.hit, also inside bold", () => {
|
||||
const el = md("find the needle here and **needle bold**", "NEEDLE");
|
||||
const marks = el.querySelectorAll("mark.hit");
|
||||
expect(marks).toHaveLength(2);
|
||||
expect(marks[0]?.textContent).toBe("needle");
|
||||
expect(el.querySelector("strong mark.hit")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("a query with no match renders plain text", () => {
|
||||
const el = md("nothing to see", "zzz");
|
||||
expect(el.querySelector("mark")).toBeNull();
|
||||
expect(el.textContent).toBe("nothing to see");
|
||||
});
|
||||
|
||||
it("code fences and inline code stay unhighlighted", () => {
|
||||
const el = md("```\nneedle\n```\nand `needle` done", "needle");
|
||||
expect(el.querySelectorAll("mark.hit")).toHaveLength(0);
|
||||
expect(el.textContent).toContain("needle");
|
||||
});
|
||||
|
||||
it("renders inside a bubble text node", () => {
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({ text: "**b** `c` [l](https://a.dev)" })}
|
||||
tools={new Map()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("b").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("c").tagName).toBe("CODE");
|
||||
expect(screen.getByText("l")).toHaveAttribute("href", "https://a.dev");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatStream search overlay", () => {
|
||||
const needles = (): ChatMessage[] => [
|
||||
msg({ key: "a", role: "user", text: "alpha NEEDLE one" }),
|
||||
msg({ key: "b", role: "assistant", text: "no match here" }),
|
||||
msg({ key: "c", role: "assistant", text: "second needle" }),
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView;
|
||||
});
|
||||
|
||||
it("filters messages case-insensitively over text, shows n/m and marks hits", () => {
|
||||
render(stream({ messages: needles(), busy: false, searchOpen: true }));
|
||||
const input = screen.getByLabelText("Search");
|
||||
fireEvent.change(input, { target: { value: "needle" } });
|
||||
expect(screen.getByText("1/2")).toBeInTheDocument();
|
||||
expect(document.querySelectorAll("mark.hit")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("matches also thinking and tool names, not just text", () => {
|
||||
const messages = [
|
||||
msg({ key: "t", text: "x", thinking: "hidden thought" }),
|
||||
msg({
|
||||
key: "u",
|
||||
text: "plain",
|
||||
toolCalls: [{ id: "c1", name: "deploy", argsJson: "{}" }],
|
||||
}),
|
||||
msg({ key: "v", text: "nothing" }),
|
||||
];
|
||||
const { rerender } = render(
|
||||
stream({ messages, busy: false, searchOpen: true }),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Search"), {
|
||||
target: { value: "hidden" },
|
||||
});
|
||||
expect(screen.getByText("1/1")).toBeInTheDocument();
|
||||
rerender(
|
||||
stream({
|
||||
messages: [messages[1]!, messages[2]!],
|
||||
busy: false,
|
||||
searchOpen: true,
|
||||
}),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Search"), {
|
||||
target: { value: "deploy" },
|
||||
});
|
||||
expect(screen.getByText("1/1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Enter/Shift+Enter cycle matches and scroll them into view", () => {
|
||||
render(stream({ messages: needles(), busy: false, searchOpen: true }));
|
||||
const input = screen.getByLabelText("Search");
|
||||
fireEvent.change(input, { target: { value: "needle" } });
|
||||
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByText("2/2")).toBeInTheDocument();
|
||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalled();
|
||||
expect(
|
||||
document.querySelector('[data-key="c"]')?.closest(".msg-current"),
|
||||
).not.toBeNull();
|
||||
|
||||
fireEvent.keyDown(input, { key: "Enter" }); // wraps around
|
||||
expect(screen.getByText("1/2")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: true }); // back to 2
|
||||
expect(screen.getByText("2/2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("no matches shows 0/0 and Enter is a no-op", () => {
|
||||
render(stream({ messages: needles(), busy: false, searchOpen: true }));
|
||||
const input = screen.getByLabelText("Search");
|
||||
fireEvent.change(input, { target: { value: "zzz" } });
|
||||
expect(screen.getByText("0/0")).toBeInTheDocument();
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByText("0/0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("empty query clears highlights and count", () => {
|
||||
const { rerender } = render(
|
||||
stream({ messages: needles(), busy: false, searchOpen: true }),
|
||||
);
|
||||
const input = screen.getByLabelText("Search");
|
||||
fireEvent.change(input, { target: { value: "needle" } });
|
||||
expect(document.querySelectorAll("mark.hit")).toHaveLength(2);
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
expect(document.querySelectorAll("mark.hit")).toHaveLength(0);
|
||||
expect(screen.queryByText(/^\d+\/\d+$/)).toBeNull();
|
||||
// same when reopened after closing with a query typed
|
||||
fireEvent.change(input, { target: { value: "needle" } });
|
||||
rerender(stream({ messages: needles(), busy: false, searchOpen: false }));
|
||||
rerender(stream({ messages: needles(), busy: false, searchOpen: true }));
|
||||
const reopened = screen.getByLabelText("Search") as HTMLInputElement;
|
||||
expect(reopened.value).toBe("");
|
||||
expect(document.querySelectorAll("mark.hit")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("Esc closes the overlay via onSearchClose", () => {
|
||||
const onSearchClose = vi.fn();
|
||||
render(
|
||||
stream({
|
||||
messages: needles(),
|
||||
busy: false,
|
||||
searchOpen: true,
|
||||
onSearchClose,
|
||||
}),
|
||||
);
|
||||
fireEvent.keyDown(screen.getByLabelText("Search"), { key: "Escape" });
|
||||
expect(onSearchClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a shrunken match set clamps the cursor", () => {
|
||||
const { rerender } = render(
|
||||
stream({ messages: needles(), busy: false, searchOpen: true }),
|
||||
);
|
||||
const input = screen.getByLabelText("Search");
|
||||
fireEvent.change(input, { target: { value: "needle" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByText("2/2")).toBeInTheDocument();
|
||||
|
||||
// live events remove the second match under the cursor
|
||||
rerender(
|
||||
stream({
|
||||
messages: [needles()[0]!, needles()[1]!],
|
||||
busy: false,
|
||||
searchOpen: true,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("1/1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatStream scroll FAB", () => {
|
||||
function sized(h: number, top: number): void {
|
||||
const scroller = document.querySelector(".chat-scroll") as HTMLElement;
|
||||
Object.defineProperty(scroller, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: h,
|
||||
});
|
||||
Object.defineProperty(scroller, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 300,
|
||||
});
|
||||
Object.defineProperty(scroller, "scrollTop", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: top,
|
||||
});
|
||||
fireEvent.scroll(scroller);
|
||||
}
|
||||
|
||||
it("hidden while near the bottom, appears past 400px away", () => {
|
||||
render(stream({ messages: [msg({ key: "a" })], busy: false }));
|
||||
sized(2000, 1700); // 0px away
|
||||
expect(screen.queryByRole("button", { name: "Jump to latest" })).toBeNull();
|
||||
sized(2000, 1301); // 399px away
|
||||
expect(screen.queryByRole("button", { name: "Jump to latest" })).toBeNull();
|
||||
sized(2000, 1299); // 401px away
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Jump to latest" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking the FAB pins and jumps to the bottom", () => {
|
||||
const { rerender } = render(
|
||||
stream({ messages: [msg({ key: "a" })], busy: false }),
|
||||
);
|
||||
sized(2000, 0);
|
||||
const fab = screen.getByRole("button", { name: "Jump to latest" });
|
||||
fireEvent.click(fab);
|
||||
const scroller = document.querySelector(".chat-scroll") as HTMLElement;
|
||||
expect(scroller.scrollTop).toBe(2000);
|
||||
expect(screen.queryByRole("button", { name: "Jump to latest" })).toBeNull();
|
||||
|
||||
// pinned again: a new message keeps the view at the bottom (no FAB)
|
||||
rerender(
|
||||
stream({ messages: [msg({ key: "a" }), msg({ key: "b" })], busy: false }),
|
||||
);
|
||||
expect(scroller.scrollTop).toBe(2000);
|
||||
expect(screen.queryByRole("button", { name: "Jump to latest" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatStream timestamps", () => {
|
||||
const TS: number = Date.UTC(2024, 0, 2, 10, 30);
|
||||
|
||||
it("hidden by default, title attr on the row always", () => {
|
||||
const { container, rerender } = render(
|
||||
stream({
|
||||
messages: [msg({ key: "a", ts: TS })],
|
||||
busy: false,
|
||||
showTs: false,
|
||||
}),
|
||||
);
|
||||
expect(container.querySelector(".ts")).toBeNull();
|
||||
expect(container.querySelector(".bubble-row")).toHaveAttribute(
|
||||
"title",
|
||||
"2024-01-02T10:30:00.000Z",
|
||||
);
|
||||
|
||||
rerender(
|
||||
stream({
|
||||
messages: [msg({ key: "a", ts: TS })],
|
||||
busy: false,
|
||||
showTs: true,
|
||||
}),
|
||||
);
|
||||
expect(container.querySelector(".ts")).not.toBeNull();
|
||||
expect(container.querySelector(".ts")).toHaveAttribute(
|
||||
"title",
|
||||
"2024-01-02T10:30:00.000Z",
|
||||
);
|
||||
expect(container.querySelector(".ts")?.textContent).toMatch(/^\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
it("ts 0 (streaming) renders nothing", () => {
|
||||
const { container } = render(
|
||||
stream({
|
||||
messages: [msg({ key: "a", ts: 0, streaming: true })],
|
||||
busy: false,
|
||||
showTs: true,
|
||||
}),
|
||||
);
|
||||
expect(container.querySelector(".ts")).toBeNull();
|
||||
expect(container.querySelector(".bubble-row")).not.toHaveAttribute("title");
|
||||
});
|
||||
});
|
||||
|
||||
describe("copy button", () => {
|
||||
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()}
|
||||
/>,
|
||||
|
||||
+302
-26
@@ -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(
|
||||
<mark className="hit" key={`h-${n}`}>
|
||||
{text.slice(at, at + needle.length)}
|
||||
</mark>,
|
||||
);
|
||||
n += 1;
|
||||
from = at + needle.length;
|
||||
at = hay.indexOf(needle, from);
|
||||
}
|
||||
if (from < text.length) parts.push(text.slice(from));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function tokenNode(tok: string, query: string, key: string): React.ReactNode {
|
||||
if (tok.startsWith("`")) return <code key={key}>{tok.slice(1, -1)}</code>;
|
||||
if (tok.startsWith("**"))
|
||||
return <strong key={key}>{highlight(tok.slice(2, -2), query)}</strong>;
|
||||
if (tok.startsWith("*"))
|
||||
return <em key={key}>{highlight(tok.slice(1, -1), query)}</em>;
|
||||
// link token; INLINE_RE guarantees the [label](url) form
|
||||
const closeAt: number = tok.indexOf("]");
|
||||
const label: string = tok.slice(1, closeAt);
|
||||
const url: string = tok.slice(closeAt + 2, -1);
|
||||
if (SAFE_URL_RE.test(url))
|
||||
return (
|
||||
<a key={key} href={url} target="_blank" rel="noopener noreferrer">
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
// non-http scheme (javascript:, data:, …): the anchor is dropped, the
|
||||
// raw token stays visible as plain text
|
||||
return <span key={key}>{highlight(tok, query)}</span>;
|
||||
}
|
||||
|
||||
function inlineNodes(
|
||||
text: string,
|
||||
query: string,
|
||||
keyBase: string,
|
||||
): React.ReactNode[] {
|
||||
const out: React.ReactNode[] = [];
|
||||
let from = 0;
|
||||
let n = 0;
|
||||
for (const m of text.matchAll(INLINE_RE)) {
|
||||
const tok: string = m[0];
|
||||
const at: number = m.index ?? 0;
|
||||
if (at > from) out.push(highlight(text.slice(from, at), query));
|
||||
out.push(tokenNode(tok, query, `${keyBase}-${n}`));
|
||||
n += 1;
|
||||
from = at + tok.length;
|
||||
}
|
||||
if (from < text.length) out.push(highlight(text.slice(from), query));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** ```fences``` → <pre class="md-code">, `code`, **bold**, *italic*,
|
||||
* [t](http…) links (http/https only). Plain segments keep the bubble's
|
||||
* pre-wrap. Unterminated fences (streaming) render the tail as code. */
|
||||
export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
|
||||
const out: React.ReactNode[] = [];
|
||||
let plain: string[] = [];
|
||||
let code: string[] | null = null;
|
||||
let n = 0;
|
||||
const flushPlain = (): void => {
|
||||
if (plain.length > 0) {
|
||||
out.push(...inlineNodes(plain.join("\n"), query, `p-${n}`));
|
||||
n += 1;
|
||||
plain = [];
|
||||
}
|
||||
};
|
||||
for (const line of text.split("\n")) {
|
||||
if (line.trimStart().startsWith(FENCE)) {
|
||||
if (code === null) {
|
||||
flushPlain();
|
||||
code = [];
|
||||
} else {
|
||||
out.push(
|
||||
<pre className="md-code" key={`c-${n}`}>
|
||||
{code.join("\n")}
|
||||
</pre>,
|
||||
);
|
||||
n += 1;
|
||||
code = null;
|
||||
}
|
||||
} else if (code !== null) {
|
||||
code.push(line);
|
||||
} else {
|
||||
plain.push(line);
|
||||
}
|
||||
}
|
||||
if (code !== null) {
|
||||
out.push(
|
||||
<pre className="md-code" key={`c-${n}`}>
|
||||
{code.join("\n")}
|
||||
</pre>,
|
||||
);
|
||||
} else {
|
||||
flushPlain();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatTs(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function searchHaystack(m: ChatMessage): string {
|
||||
const names: string[] = m.toolCalls.map((c) => c.name);
|
||||
return `${m.text} ${m.thinking ?? ""} ${names.join(" ")}`.toLowerCase();
|
||||
}
|
||||
|
||||
function oneLine(text: string): string {
|
||||
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<string, ToolState>;
|
||||
/** live search query: matches in plain text get <mark class="hit"> */
|
||||
query?: string;
|
||||
showTs?: boolean;
|
||||
}) {
|
||||
if (msg.notice === true) {
|
||||
return (
|
||||
<div className="bubble-row system">
|
||||
<div className="notice-line">⚠ {msg.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const rowClass =
|
||||
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 (
|
||||
<div className={`bubble-row ${rowClass}`}>
|
||||
<div
|
||||
className={`bubble-row ${rowClass}`}
|
||||
data-key={msg.key}
|
||||
title={tsTitle.length > 0 ? tsTitle : undefined}
|
||||
>
|
||||
{copyable && <CopyButton text={msg.text} />}
|
||||
<div className="bubble">
|
||||
{msg.thinking !== null && msg.thinking.length > 0 && (
|
||||
@@ -114,10 +264,15 @@ export function Bubble({
|
||||
<div className="tool-body">{msg.text}</div>
|
||||
</details>
|
||||
) : (
|
||||
msg.text.length > 0 && <div>{msg.text}</div>
|
||||
msg.text.length > 0 && <div>{renderMarkdown(msg.text, query)}</div>
|
||||
)}
|
||||
{msg.streaming && <span className="stream-caret">▍</span>}
|
||||
</div>
|
||||
{showTs && msg.ts > 0 && (
|
||||
<div className="ts" title={tsTitle}>
|
||||
{formatTs(msg.ts)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
const pinnedRef = useRef<boolean>(true);
|
||||
const rowRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [matchIdx, setMatchIdx] = useState<number>(0);
|
||||
const [showFab, setShowFab] = useState<boolean>(false);
|
||||
|
||||
const awayFromBottom = (): number => {
|
||||
const el = scrollRef.current;
|
||||
if (el === null) return 0;
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
};
|
||||
|
||||
const onScroll = (): void => {
|
||||
const 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 (
|
||||
<div className="chat-scroll" ref={scrollRef} onScroll={onScroll}>
|
||||
<div className="chat-inner">
|
||||
{hasOlder && (
|
||||
<div className="load-older">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
aria-label="Load older messages"
|
||||
disabled={loadingOlder}
|
||||
onClick={onLoadOlder}
|
||||
<div className="chat-stream">
|
||||
<div className="chat-scroll" ref={scrollRef} onScroll={onScroll}>
|
||||
<div className="chat-inner">
|
||||
{hasOlder && (
|
||||
<div className="load-older">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
aria-label="Load older messages"
|
||||
disabled={loadingOlder}
|
||||
onClick={onLoadOlder}
|
||||
>
|
||||
{loadingOlder ? "loading…" : "Load older"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.key}
|
||||
className={m.key === currentKey ? "msg-current" : undefined}
|
||||
ref={(el: HTMLDivElement | null): void => {
|
||||
if (el === null) rowRefs.current.delete(m.key);
|
||||
else rowRefs.current.set(m.key, el);
|
||||
}}
|
||||
>
|
||||
{loadingOlder ? "loading…" : "Load older"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<Bubble key={m.key} msg={m} tools={tools} />
|
||||
))}
|
||||
{showTyping && <TypingIndicator />}
|
||||
<Bubble msg={m} tools={tools} query={query} showTs={showTs} />
|
||||
</div>
|
||||
))}
|
||||
{showTyping && <TypingIndicator />}
|
||||
</div>
|
||||
</div>
|
||||
{searchOpen && (
|
||||
<div className="search-bar">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={query}
|
||||
placeholder="Search…"
|
||||
aria-label="Search"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setMatchIdx(0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === ESCAPE_KEY) onSearchClose();
|
||||
else if (e.key === ENTER_KEY) {
|
||||
e.preventDefault();
|
||||
cycleMatch(e.shiftKey ? -1 : 1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{query.length > 0 && (
|
||||
<span className="search-count" aria-live="polite">
|
||||
{matches.length === 0 ? 0 : matchIdx + 1}/{matches.length}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
aria-label="Close search"
|
||||
onClick={onSearchClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showFab && (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-fab"
|
||||
aria-label="Jump to latest"
|
||||
onClick={jumpToBottom}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+741
-11
@@ -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<typeof mockFetchJson> {
|
||||
return mockFetchJson((url, init) => {
|
||||
if (init?.method === "DELETE" && url.endsWith("/events"))
|
||||
return { ok: true, deleted: 4 };
|
||||
if (init?.method === "DELETE" && url.endsWith("/api/sessions/s1"))
|
||||
return { ok: true };
|
||||
if (url.includes("/events")) return historyEvents();
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
it("menu toggle opens and closes the ⋯ dropdown", async () => {
|
||||
mockFetchJson((url) => (url.includes("/events") ? historyEvents() : []));
|
||||
renderChat(makeStore());
|
||||
await screen.findByText("hello there");
|
||||
|
||||
const toggle = screen.getByRole("button", { name: "Session menu" });
|
||||
expect(toggle).toHaveAttribute("aria-haspopup", "menu");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(toggle);
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("Escape closes the open menu", async () => {
|
||||
mockFetchJson((url) => (url.includes("/events") ? historyEvents() : []));
|
||||
renderChat(makeStore());
|
||||
await screen.findByText("hello there");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking outside closes the open menu", async () => {
|
||||
mockFetchJson((url) => (url.includes("/events") ? historyEvents() : []));
|
||||
renderChat(makeStore());
|
||||
await screen.findByText("hello there");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("Clear history: confirm deletes events, clears local state, toasts", async () => {
|
||||
const fetchMock = mockHistoryWithDeletes();
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderChat(makeStore());
|
||||
await screen.findByText("hello there");
|
||||
expect(screen.getByText("hi!")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("menuitem", { name: "Clear history" }),
|
||||
);
|
||||
await vi.waitFor(() => expect(confirm).toHaveBeenCalled());
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.queryByText("hello there")).toBeNull(),
|
||||
);
|
||||
expect(screen.queryByText("hi!")).toBeNull();
|
||||
await vi.waitFor(() =>
|
||||
expect(pushToast).toHaveBeenCalledWith("history cleared"),
|
||||
);
|
||||
const del = fetchMock.mock.calls.find(
|
||||
(c) =>
|
||||
(c[1] as RequestInit | undefined)?.method === "DELETE" &&
|
||||
String(c[0]).endsWith("/events"),
|
||||
);
|
||||
expect(del?.[0]).toBe("http://srv/api/sessions/s1/events");
|
||||
// action closes the menu
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("Clear history: cancel keeps everything", async () => {
|
||||
const fetchMock = mockHistoryWithDeletes();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
renderChat(makeStore());
|
||||
await screen.findByText("hello there");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("menuitem", { name: "Clear history" }),
|
||||
);
|
||||
await vi.waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit | undefined)?.method === "DELETE",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(screen.getByText("hello there")).toBeInTheDocument();
|
||||
expect(pushToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Clear history failure toasts the error", async () => {
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "DELETE" && url.endsWith("/events"))
|
||||
return jsonResponse({ error: "boom" }, 500);
|
||||
if (url.includes("/events")) return historyEvents();
|
||||
return [];
|
||||
});
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderChat(makeStore());
|
||||
await screen.findByText("hello there");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("menuitem", { name: "Clear history" }),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(pushToast).toHaveBeenCalledWith("clear failed: boom"),
|
||||
);
|
||||
expect(screen.getByText("hello there")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Delete session: confirm deletes, toasts and navigates home", async () => {
|
||||
const fetchMock = mockHistoryWithDeletes();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderChat(makeStore()); // "*" route renders OTHER, so / lands there
|
||||
await screen.findByText("hello there");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("menuitem", { name: "Delete session" }),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(pushToast).toHaveBeenCalledWith("deleted worker"),
|
||||
);
|
||||
const del = fetchMock.mock.calls.find(
|
||||
(c) =>
|
||||
(c[1] as RequestInit | undefined)?.method === "DELETE" &&
|
||||
String(c[0]).endsWith("/api/sessions/s1"),
|
||||
);
|
||||
expect(del?.[0]).toBe("http://srv/api/sessions/s1");
|
||||
expect(await screen.findByText("OTHER")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Delete session: cancel stays on the page", async () => {
|
||||
const fetchMock = mockHistoryWithDeletes();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
renderChat(makeStore());
|
||||
await screen.findByText("hello there");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("menuitem", { name: "Delete session" }),
|
||||
);
|
||||
await vi.waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit | undefined)?.method === "DELETE",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(screen.getByText("hello there")).toBeInTheDocument();
|
||||
expect(screen.queryByText("OTHER")).toBeNull();
|
||||
});
|
||||
|
||||
it("Delete session failure toasts the error", async () => {
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "DELETE" && url.endsWith("/api/sessions/s1"))
|
||||
return jsonResponse({ error: "nope" }, 500);
|
||||
if (url.includes("/events")) return historyEvents();
|
||||
return [];
|
||||
});
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderChat(makeStore());
|
||||
await screen.findByText("hello there");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("menuitem", { name: "Delete session" }),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(pushToast).toHaveBeenCalledWith("delete failed: nope"),
|
||||
);
|
||||
expect(screen.getByText("hello there")).toBeInTheDocument();
|
||||
expect(screen.queryByText("OTHER")).toBeNull();
|
||||
});
|
||||
|
||||
it("unknown session confirms with the id and still clears", async () => {
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
mockFetchJson((_url, init) => {
|
||||
if (init?.method === "DELETE") return { ok: true, deleted: 0 };
|
||||
return [];
|
||||
});
|
||||
renderChat(makeStore(), "/s/ghost");
|
||||
await screen.findByRole("button", { name: "Session menu" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("menuitem", { name: "Clear history" }),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(confirm).toHaveBeenCalledWith("Clear all history for ghost?"),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(pushToast).toHaveBeenCalledWith("history cleared"),
|
||||
);
|
||||
});
|
||||
|
||||
it("unknown session delete confirms and toasts with the id", async () => {
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
mockFetchJson((_url, init) => {
|
||||
if (init?.method === "DELETE") return { ok: true };
|
||||
return [];
|
||||
});
|
||||
renderChat(makeStore(), "/s/ghost");
|
||||
await screen.findByRole("button", { name: "Session menu" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("menuitem", { name: "Delete session" }),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
"Delete session ghost? This cannot be undone.",
|
||||
),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(pushToast).toHaveBeenCalledWith("deleted ghost"),
|
||||
);
|
||||
expect(await screen.findByText("OTHER")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("unnamed session confirms and toasts with the id", async () => {
|
||||
const unnamed = [{ ...sessions[0]!, name: null }];
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "DELETE") return { ok: true };
|
||||
if (url.includes("/events")) return historyEvents();
|
||||
return [];
|
||||
});
|
||||
renderChat(makeStore({ sessions: unnamed }));
|
||||
await screen.findByText("hello there");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("menuitem", { name: "Clear history" }),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(confirm).toHaveBeenCalledWith("Clear all history for s1?"),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Session menu" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("menuitem", { name: "Delete session" }),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
"Delete session s1? This cannot be undone.",
|
||||
),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(pushToast).toHaveBeenCalledWith("deleted s1"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView usage chip", () => {
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="switch session"
|
||||
onClick={() => nav("/s/ghost")}
|
||||
>
|
||||
switch
|
||||
</button>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/s/:id"
|
||||
element={<ChatView store={makeStore()} pushToast={pushToast} />}
|
||||
/>
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/s/s1"]}>
|
||||
<Switcher />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const ta = (await screen.findByLabelText("Message")) as HTMLTextAreaElement;
|
||||
await userEvent.type(ta, "secret for s1");
|
||||
fireEvent.keyDown(ta, { key: "Enter" });
|
||||
await vi.waitFor(() => expect(ta.value).toBe(""));
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "switch session" }),
|
||||
);
|
||||
const ta2 = (await screen.findByLabelText(
|
||||
"Message",
|
||||
)) as HTMLTextAreaElement;
|
||||
fireEvent.keyDown(ta2, { key: "ArrowUp" });
|
||||
expect(ta2.value).toBe(""); // s1's sent history must not leak into ghost
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView search overlay", () => {
|
||||
it("the 🔍 button opens the overlay, focused; Esc closes it", async () => {
|
||||
mockFetchJson((url) =>
|
||||
url.startsWith("http://srv/api/sessions/s1/events")
|
||||
? historyEvents()
|
||||
: [],
|
||||
);
|
||||
renderChat(makeStore());
|
||||
await screen.findByText("hello there");
|
||||
|
||||
expect(screen.queryByLabelText("Search")).toBeNull();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Search in conversation" }),
|
||||
);
|
||||
const input = screen.getByLabelText("Search");
|
||||
expect(input).toHaveFocus();
|
||||
|
||||
// live filter over loaded history
|
||||
await userEvent.type(input, "hello");
|
||||
expect(screen.getByText("1/1")).toBeInTheDocument();
|
||||
expect(document.querySelectorAll("mark.hit").length).toBe(1);
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(screen.queryByLabelText("Search")).toBeNull();
|
||||
expect(document.querySelectorAll("mark.hit").length).toBe(0);
|
||||
});
|
||||
|
||||
it("'/' opens the overlay unless typing in an input", async () => {
|
||||
mockFetchJson(() => []);
|
||||
renderChat(makeStore());
|
||||
const ta = (await screen.findByLabelText("Message")) as HTMLTextAreaElement;
|
||||
|
||||
// '/' typed into the composer stays a character
|
||||
await userEvent.type(ta, "/");
|
||||
expect(screen.queryByLabelText("Search")).toBeNull();
|
||||
expect(ta.value).toBe("/");
|
||||
await userEvent.clear(ta);
|
||||
|
||||
// with modifiers it is ignored
|
||||
fireEvent.keyDown(window, { key: "/", ctrlKey: true });
|
||||
expect(screen.queryByLabelText("Search")).toBeNull();
|
||||
|
||||
// bare '/' anywhere else opens the overlay
|
||||
fireEvent.keyDown(window, { key: "/" });
|
||||
expect(screen.getByLabelText("Search")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView ⋯ menu and timestamps", () => {
|
||||
function tsEvents(): EventFrame[] {
|
||||
seq = 0;
|
||||
return [
|
||||
ev("message_end", {
|
||||
ts: 1000,
|
||||
message: {
|
||||
role: "user",
|
||||
id: "u1",
|
||||
text: "hello there",
|
||||
thinking: null,
|
||||
toolCalls: [],
|
||||
toolCallId: null,
|
||||
},
|
||||
}),
|
||||
ev("agent_settled", { ts: 1500 }),
|
||||
];
|
||||
}
|
||||
|
||||
it("Show timestamps is off by default and toggles the .ts lines", async () => {
|
||||
mockFetchJson((url) =>
|
||||
url.startsWith("http://srv/api/sessions/s1/events") ? tsEvents() : [],
|
||||
);
|
||||
const { container } = renderChat(makeStore());
|
||||
await screen.findByText("hello there");
|
||||
expect(container.querySelectorAll(".ts")).toHaveLength(0);
|
||||
// the timestamp is still reachable via the row title attr
|
||||
expect(container.querySelector(".bubble-row")).toHaveAttribute(
|
||||
"title",
|
||||
new Date(1000).toISOString(),
|
||||
);
|
||||
|
||||
const menu = screen.getByRole("button", { name: "Session menu" });
|
||||
expect(menu).toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(menu);
|
||||
const item = screen.getByRole("menuitemcheckbox", {
|
||||
name: /Show timestamps/,
|
||||
});
|
||||
expect(item).toHaveAttribute("aria-checked", "false");
|
||||
fireEvent.click(item);
|
||||
expect(item).toHaveAttribute("aria-checked", "true");
|
||||
expect(container.querySelectorAll(".ts")).toHaveLength(1);
|
||||
|
||||
// click outside closes the menu; the choice persists
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(menu).toHaveAttribute("aria-expanded", "false");
|
||||
expect(container.querySelectorAll(".ts")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
+208
-23
@@ -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<EventFrame[]>([]);
|
||||
const [loadError, setLoadError] = useState<string>("");
|
||||
@@ -80,10 +87,17 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
const [switching, setSwitching] = useState<boolean>(false);
|
||||
const [renaming, setRenaming] = useState<boolean>(false);
|
||||
const [renameDraft, setRenameDraft] = useState<string>("");
|
||||
const [searchOpen, setSearchOpen] = useState<boolean>(false);
|
||||
const [menuOpen, setMenuOpen] = useState<boolean>(false);
|
||||
const [showTs, setShowTs] = useState<boolean>(false);
|
||||
const [stats, setStats] = useState<SessionStats | null>(null);
|
||||
|
||||
const lastSeqRef = useRef<number>(0);
|
||||
const loadedRef = useRef<boolean>(false);
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
// last sent messages for ArrowUp recall (per session, in-memory)
|
||||
const sentRef = useRef<string[]>([]);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
// guards async fetches against session switches (S1)
|
||||
const sessionIdRef = useRef<string>(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<SessionStats>(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<void> => {
|
||||
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<void> => {
|
||||
setMenuOpen(false);
|
||||
if (!window.confirm(`Clear all history for ${session?.name ?? sessionId}?`))
|
||||
return;
|
||||
try {
|
||||
await fetchJson(Route.SessionEvents(sessionId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
setEvents([]);
|
||||
lastSeqRef.current = 0;
|
||||
setHasOlder(false);
|
||||
pushToast("history cleared");
|
||||
} catch (err) {
|
||||
pushToast(`clear failed: ${errMessage(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSessionAction = async (): Promise<void> => {
|
||||
setMenuOpen(false);
|
||||
if (
|
||||
!window.confirm(
|
||||
`Delete session ${session?.name ?? sessionId}? This cannot be undone.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await fetchJson(Route.Session(sessionId), { method: "DELETE" });
|
||||
pushToast(`deleted ${session?.name ?? sessionId}`);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
pushToast(`delete failed: ${errMessage(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
// 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) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(chat.usage.inputTokens > 0 || chat.usage.outputTokens > 0) && (
|
||||
<span className="usage-chip" title="tokens this session">
|
||||
↑{chat.usage.inputTokens.toLocaleString()} ↓
|
||||
{chat.usage.outputTokens.toLocaleString()}
|
||||
{chat.usage.totalCost > 0 &&
|
||||
` · $${chat.usage.totalCost.toFixed(2)}`}
|
||||
</span>
|
||||
)}
|
||||
{stats !== null &&
|
||||
(stats.inputTokens > 0 || stats.outputTokens > 0) && (
|
||||
<span
|
||||
className="usage-chip"
|
||||
title={`↑${stats.inputTokens.toLocaleString()} ↓${stats.outputTokens.toLocaleString()} · ${stats.turns} turns · $${stats.totalCost.toFixed(2)}`}
|
||||
>
|
||||
↑{formatTokens(stats.inputTokens)} ↓
|
||||
{formatTokens(stats.outputTokens)} · {stats.turns} turns · $
|
||||
{stats.totalCost.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={classNames(
|
||||
"conn-dot",
|
||||
@@ -419,6 +547,60 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
title={session?.online === true ? "online" : "offline"}
|
||||
/>
|
||||
<div className="spacer" />
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
aria-label="Search in conversation"
|
||||
aria-expanded={searchOpen}
|
||||
onClick={() => setSearchOpen(true)}
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
<div className="menu-anchor" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
aria-label="Session menu"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="chat-menu"
|
||||
role="menu"
|
||||
aria-label="Session actions"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-menu-item"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={showTs}
|
||||
onClick={() => setShowTs(!showTs)}
|
||||
>
|
||||
{showTs ? "✓" : " "} Show timestamps
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="chat-menu-item"
|
||||
onClick={() => void clearHistory()}
|
||||
>
|
||||
Clear history
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="chat-menu-item danger"
|
||||
onClick={() => void deleteSessionAction()}
|
||||
>
|
||||
Delete session
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{session?.agent === true && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -458,6 +640,9 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
hasOlder={hasOlder}
|
||||
loadingOlder={loadingOlder}
|
||||
onLoadOlder={() => void loadOlder()}
|
||||
searchOpen={searchOpen}
|
||||
onSearchClose={() => setSearchOpen(false)}
|
||||
showTs={showTs}
|
||||
/>
|
||||
)}
|
||||
<div className="composer">
|
||||
@@ -466,7 +651,7 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
ref={taRef}
|
||||
rows={1}
|
||||
value={draft}
|
||||
placeholder="Message…"
|
||||
placeholder={busy ? PLACEHOLDER_BUSY : PLACEHOLDER_IDLE}
|
||||
aria-label="Message"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useParams } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SessionListItem } from "./protocol";
|
||||
@@ -144,6 +150,69 @@ describe("SessionsView", () => {
|
||||
expect(screen.queryByLabelText(/Stop container/)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the global usage line from GET /api/stats", async () => {
|
||||
seedSettings();
|
||||
mockFetchJson((url) => {
|
||||
if (url === "http://srv/api/stats")
|
||||
return {
|
||||
turns: 4210,
|
||||
inputTokens: 1200000,
|
||||
outputTokens: 340000,
|
||||
totalCost: 4.2,
|
||||
sessionsCount: 5,
|
||||
onlineCount: 2,
|
||||
};
|
||||
return [];
|
||||
});
|
||||
renderView({ sessions: [session({ id: "s1", name: "live" })] });
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/4210 turns · 1\.2M↑ 340K↓ · \$4\.20 · 5 sessions/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTitle("2 online")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refetches global stats when the session list changes", async () => {
|
||||
seedSettings();
|
||||
const fetchMock = mockFetchJson((url) => {
|
||||
if (url === "http://srv/api/stats")
|
||||
return {
|
||||
turns: 1,
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.01,
|
||||
sessionsCount: 1,
|
||||
onlineCount: 0,
|
||||
};
|
||||
return [];
|
||||
});
|
||||
const { rerender } = renderView({ sessions: [] });
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([u]) => String(u).endsWith("/api/stats"))
|
||||
.length,
|
||||
).toBe(1),
|
||||
);
|
||||
|
||||
// a WS session_list push replaces the array: the line must refresh
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<SessionsView
|
||||
sessions={[session({ id: "s2", name: "new" })]}
|
||||
onChanged={() => undefined}
|
||||
pushToast={() => undefined}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([u]) => String(u).endsWith("/api/stats"))
|
||||
.length,
|
||||
).toBe(2),
|
||||
);
|
||||
});
|
||||
|
||||
it("keyboard Enter and Space open the session; other keys ignored", () => {
|
||||
const probe = vi.fn();
|
||||
render(
|
||||
@@ -264,7 +333,10 @@ describe("SessionsView", () => {
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Stop container for worker"));
|
||||
await vi.waitFor(() => {
|
||||
const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
const call = fetchMock.mock.calls.find(([u]) =>
|
||||
String(u).endsWith("/api/sessions/s1/container"),
|
||||
) as unknown as [string, RequestInit];
|
||||
expect(call).toBeDefined();
|
||||
expect(call[0]).toBe("http://srv/api/sessions/s1/container");
|
||||
expect(call[1].method).toBe("DELETE");
|
||||
});
|
||||
@@ -306,3 +378,121 @@ describe("SessionsView", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionsView delete session", () => {
|
||||
it("trash button deletes the session, toasts and refreshes", async () => {
|
||||
seedSettings();
|
||||
const fetchMock = mockFetchJson(() => ({ ok: true }));
|
||||
const onChanged = vi.fn();
|
||||
const pushToast = vi.fn();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderView({
|
||||
sessions: [session({ id: "s1", name: "worker", agent: false })],
|
||||
onChanged,
|
||||
pushToast,
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Delete session worker"));
|
||||
await vi.waitFor(() => {
|
||||
const call = fetchMock.mock.calls.find(([u]) =>
|
||||
String(u).endsWith("/api/sessions/s1"),
|
||||
) as unknown as [string, RequestInit];
|
||||
expect(call).toBeDefined();
|
||||
expect(call[0]).toBe("http://srv/api/sessions/s1");
|
||||
expect(call[1].method).toBe("DELETE");
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(pushToast).toHaveBeenCalledWith("deleted worker"),
|
||||
);
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancel keeps the session untouched", async () => {
|
||||
seedSettings();
|
||||
const fetchMock = mockFetchJson(() => ({ ok: true }));
|
||||
const onChanged = vi.fn();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
renderView({
|
||||
sessions: [session({ id: "s1", name: "worker" })],
|
||||
onChanged,
|
||||
pushToast: () => undefined,
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Delete session worker"));
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([u]) => !String(u).endsWith("/api/stats")),
|
||||
).toHaveLength(0);
|
||||
expect(onChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delete failure toasts the error", async () => {
|
||||
seedSettings();
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
jsonResponse({ error: "nope" }, 500),
|
||||
);
|
||||
const pushToast = vi.fn();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderView({
|
||||
sessions: [session({ id: "s1", name: "w" })],
|
||||
pushToast,
|
||||
onChanged: () => undefined,
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Delete session w"));
|
||||
await vi.waitFor(() =>
|
||||
expect(pushToast).toHaveBeenCalledWith("delete failed: nope"),
|
||||
);
|
||||
});
|
||||
|
||||
it("unnamed session confirms and toasts with the id", async () => {
|
||||
seedSettings();
|
||||
mockFetchJson(() => ({ ok: true }));
|
||||
const pushToast = vi.fn();
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
renderView({
|
||||
sessions: [session({ id: "s1", name: null })],
|
||||
pushToast,
|
||||
onChanged: () => undefined,
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Delete session s1"));
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
"Delete session s1? This cannot be undone.",
|
||||
);
|
||||
});
|
||||
|
||||
it("unnamed session delete success toasts the id", async () => {
|
||||
seedSettings();
|
||||
mockFetchJson(() => ({ ok: true }));
|
||||
const pushToast = vi.fn();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderView({
|
||||
sessions: [session({ id: "s1", name: null })],
|
||||
pushToast,
|
||||
onChanged: () => undefined,
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Delete session s1"));
|
||||
await vi.waitFor(() =>
|
||||
expect(pushToast).toHaveBeenCalledWith("deleted s1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("non-error delete failure path stringifies non-Error throws", async () => {
|
||||
seedSettings();
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string");
|
||||
const pushToast = vi.fn();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderView({
|
||||
sessions: [session({ id: "s1", name: "w" })],
|
||||
pushToast,
|
||||
onChanged: () => undefined,
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Delete session w"));
|
||||
await vi.waitFor(() =>
|
||||
expect(pushToast).toHaveBeenCalledWith("delete failed: plain-string"),
|
||||
);
|
||||
});
|
||||
|
||||
it("archived sessions carry the trash button too", () => {
|
||||
renderView({
|
||||
sessions: [session({ id: "off", name: "dead", online: false })],
|
||||
});
|
||||
expect(screen.getByLabelText("Delete session dead")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type MouseEvent, useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { SessionListItem } from "./protocol";
|
||||
import type { SessionListItem, StatsTotals } from "./protocol";
|
||||
import { Route } from "./protocol";
|
||||
import { fetchJson } from "./api";
|
||||
import { classNames, relativeTime } from "./store";
|
||||
import { classNames, formatTokens, relativeTime } from "./store";
|
||||
|
||||
interface Props {
|
||||
sessions: SessionListItem[];
|
||||
@@ -15,10 +15,12 @@ function SessionCard({
|
||||
s,
|
||||
onOpen,
|
||||
onStop,
|
||||
onDelete,
|
||||
}: {
|
||||
s: SessionListItem;
|
||||
onOpen: (id: string) => void;
|
||||
onStop: (e: MouseEvent, s: SessionListItem) => void;
|
||||
onDelete: (e: MouseEvent, s: SessionListItem) => void;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
@@ -61,6 +63,14 @@ function SessionCard({
|
||||
■
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn danger"
|
||||
aria-label={`Delete session ${s.name ?? s.id}`}
|
||||
onClick={(e) => onDelete(e, s)}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -72,6 +82,21 @@ export default function SessionsView({
|
||||
}: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [loaded, setLoaded] = useState<boolean>(false);
|
||||
const [stats, setStats] = useState<StatsTotals | null>(null);
|
||||
|
||||
// global usage line: fetched on mount and whenever the session list
|
||||
// changes (each WS session_list push is a fresh array)
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void fetchJson<StatsTotals>(Route.Stats)
|
||||
.then((s) => {
|
||||
if (alive) setStats(s);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return (): void => {
|
||||
alive = false;
|
||||
};
|
||||
}, [sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
// 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<void> => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (
|
||||
!window.confirm(
|
||||
`Delete session ${s.name ?? s.id}? This cannot be undone.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await fetchJson(Route.Session(s.id), { method: "DELETE" });
|
||||
pushToast(`deleted ${s.name ?? s.id}`);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
pushToast(
|
||||
`delete failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!loaded && sessions.length === 0) {
|
||||
return (
|
||||
<div className="page" aria-busy="true">
|
||||
@@ -131,6 +176,13 @@ export default function SessionsView({
|
||||
<span className="count">
|
||||
{active.length} active · {archived.length} archived
|
||||
</span>
|
||||
{stats !== null && typeof stats.totalCost === "number" && (
|
||||
<span className="count" title={`${stats.onlineCount} online`}>
|
||||
{stats.turns} turns · {formatTokens(stats.inputTokens)}↑{" "}
|
||||
{formatTokens(stats.outputTokens)}↓ · ${stats.totalCost.toFixed(2)}{" "}
|
||||
· {stats.sessionsCount} sessions
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{active.length === 0 && (
|
||||
<p className="empty">No active sessions. Spawn one from the sidebar.</p>
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
+174
-9
@@ -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<typeof mockFetchJson> {
|
||||
return mockFetchJson((url, init) => {
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "a" };
|
||||
if (url.endsWith("/api/gitlab/repos"))
|
||||
return [repo("g/alpha"), repo("g/beta"), repo("g/plain")];
|
||||
if (url.endsWith("/api/repos")) return registry;
|
||||
if (url.endsWith("/api/repos/g/alpha/prepare") && init?.method === "POST")
|
||||
return prepareStatus === 200
|
||||
? { ok: true }
|
||||
: jsonResponse({ error: "ops session offline" }, prepareStatus);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
it("badges: green when built, amber needs-build when missing, tooltips carry the image", async () => {
|
||||
registryMock();
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
|
||||
const ok = screen.getByText("custom image");
|
||||
expect(ok.className).toContain("ok");
|
||||
expect(ok).toHaveAttribute(
|
||||
"title",
|
||||
"custom image lvmh-worker-alpha — built",
|
||||
);
|
||||
|
||||
const warn = screen.getByText("needs build");
|
||||
expect(warn.className).toContain("warn");
|
||||
expect(warn).toHaveAttribute(
|
||||
"title",
|
||||
"custom image lvmh-worker-beta not built — Prepare asks ops to build it",
|
||||
);
|
||||
|
||||
// unregistered repos get no badge
|
||||
const plain = screen.getByText("g/plain").closest(".repo-item");
|
||||
expect(plain?.querySelector(".badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("prepare POSTs and toasts, without selecting the row", async () => {
|
||||
const fetchMock = registryMock();
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Prepare g/alpha via ops" }),
|
||||
);
|
||||
await flush();
|
||||
|
||||
const prepareCalls = fetchMock.mock.calls.filter(
|
||||
([u, i]) =>
|
||||
String(u).endsWith("/api/repos/g/alpha/prepare") &&
|
||||
i?.method === "POST",
|
||||
);
|
||||
expect(prepareCalls).toHaveLength(1);
|
||||
expect(PUSH_TOAST).toHaveBeenCalledWith(
|
||||
"ops preparing g/alpha — watch lvmh-ops-control",
|
||||
);
|
||||
// clicking Prepare must not pick the row
|
||||
expect(
|
||||
screen
|
||||
.getByText("g/alpha")
|
||||
.closest(".repo-item")
|
||||
?.getAttribute("aria-pressed"),
|
||||
).toBe("false");
|
||||
});
|
||||
|
||||
it("prepare Enter key does not select the row either (stopPropagation)", async () => {
|
||||
const fetchMock = registryMock();
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Prepare g/beta via ops" });
|
||||
fireEvent.keyDown(btn, { key: "Enter" });
|
||||
await flush();
|
||||
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([u]) => String(u).endsWith("/prepare")),
|
||||
).toBe(false); // jsdom synthesizes no click; only the guard ran
|
||||
expect(
|
||||
screen
|
||||
.getByText("g/beta")
|
||||
.closest(".repo-item")
|
||||
?.getAttribute("aria-pressed"),
|
||||
).toBe("false");
|
||||
});
|
||||
|
||||
it("prepare failure toasts the daemon error", async () => {
|
||||
registryMock(409);
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Prepare g/alpha via ops" }),
|
||||
);
|
||||
await flush();
|
||||
expect(PUSH_TOAST).toHaveBeenCalledWith("ops session offline");
|
||||
});
|
||||
|
||||
it("registry fetch failure hides badges without breaking the picker", async () => {
|
||||
mockFetchJson((url) => {
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "a" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha")];
|
||||
if (url.endsWith("/api/repos"))
|
||||
return jsonResponse({ error: "boom" }, 500);
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
expect(screen.getByText("g/alpha")).toBeInTheDocument();
|
||||
expect(document.querySelector(".badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("confirmation panel names the registered image or the base image", async () => {
|
||||
registryMock();
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
|
||||
expect(screen.queryByText(/^Will use:/)).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByText("g/alpha"));
|
||||
expect(screen.getByText("Will use: lvmh-worker-alpha")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("g/plain"));
|
||||
expect(screen.getByText("Will use: base worker image")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("job line appends the image message on running jobs", async () => {
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||
return { sessionId: "sx", imageUsed: "lvmh-worker-alpha" };
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "a" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha")];
|
||||
return [];
|
||||
});
|
||||
const store = makeStore({
|
||||
spawnJobs: [
|
||||
{
|
||||
sessionId: "sx",
|
||||
repo: "g/alpha",
|
||||
state: "running",
|
||||
containerId: "cid",
|
||||
message: "image lvmh-worker-alpha",
|
||||
},
|
||||
],
|
||||
});
|
||||
render(tree(store));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("g/alpha"));
|
||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||
await flush();
|
||||
expect(
|
||||
screen.getByText("g/alpha: running (image lvmh-worker-alpha)"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+105
-29
@@ -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<GitlabStatus | null>(null);
|
||||
const [pat, setPat] = useState<string>("");
|
||||
const [repos, setRepos] = useState<Repo[] | null>(null);
|
||||
const [repoImages, setRepoImages] = useState<RepoImage[]>([]);
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [selected, setSelected] = useState<Repo | null>(null);
|
||||
const [branch, setBranch] = useState<string>("");
|
||||
@@ -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<void> => {
|
||||
try {
|
||||
setRepoImages(await fetchJson<RepoImage[]>(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<void> => {
|
||||
try {
|
||||
await fetchJson(Route.RepoPrepare(repo.path), { method: "POST" });
|
||||
pushToast(`ops preparing ${repo.path} — watch ${OPS_SESSION_ID}`);
|
||||
} catch (err) {
|
||||
pushToast(errMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const filtered: Repo[] = (repos ?? []).filter((r) =>
|
||||
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) {
|
||||
<h2>Spawning…</h2>
|
||||
<SpawnSteps state={jobState()} />
|
||||
<p className="spawn-job-line">{jobLine()}</p>
|
||||
<p className="repo-meta">
|
||||
container {spawning.containerId.slice(0, 12)}
|
||||
</p>
|
||||
<p className="repo-meta">image {spawning.imageUsed}</p>
|
||||
{error.length > 0 && <p className="error-text">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
@@ -261,31 +300,62 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
<p className="repo-meta">no matching repos</p>
|
||||
)}
|
||||
<div className="repo-list" style={{ marginTop: 10 }}>
|
||||
{filtered.map((r) => (
|
||||
<div
|
||||
key={r.path}
|
||||
className={`repo-item ${selected?.path === r.path ? "selected" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected?.path === r.path}
|
||||
onClick={() => pick(r)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
// role=button: keep Space from scrolling the page
|
||||
e.preventDefault();
|
||||
pick(r);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="repo-path">{r.path}</div>
|
||||
<div className="repo-meta">
|
||||
default {r.defaultBranch} ·{" "}
|
||||
{r.lastActivityAt.slice(0, 10)}
|
||||
{filtered.map((r) => {
|
||||
const reg = registration(r.path);
|
||||
return (
|
||||
<div
|
||||
key={r.path}
|
||||
className={`repo-item ${selected?.path === r.path ? "selected" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected?.path === r.path}
|
||||
onClick={() => pick(r)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
// role=button: keep Space from scrolling the page
|
||||
e.preventDefault();
|
||||
pick(r);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="repo-path">{r.path}</div>
|
||||
<div className="repo-meta">
|
||||
default {r.defaultBranch} ·{" "}
|
||||
{r.lastActivityAt.slice(0, 10)}
|
||||
</div>
|
||||
</div>
|
||||
{reg !== undefined && (
|
||||
<span
|
||||
className={reg.built ? "badge ok" : "badge warn"}
|
||||
title={
|
||||
reg.built
|
||||
? `custom image ${reg.image} — built`
|
||||
: `custom image ${reg.image} not built — Prepare asks ops to build it`
|
||||
}
|
||||
>
|
||||
{reg.built ? "custom image" : "needs build"}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="prepare-btn"
|
||||
aria-label={`Prepare ${r.path} via ops`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void prepare(r);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// let the native button handle Enter/Space
|
||||
if (e.key === "Enter" || e.key === " ")
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
⚡ Prepare
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -315,6 +385,12 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
select a repo above
|
||||
</p>
|
||||
)}
|
||||
{selected !== null && (
|
||||
<p className="repo-meta" style={{ marginTop: 8 }}>
|
||||
Will use:{" "}
|
||||
{registration(selected.path)?.image ?? "base worker image"}
|
||||
</p>
|
||||
)}
|
||||
{error.length > 0 && <p className="error-text">{error}</p>}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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<EventFrame> & { 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" }),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <ref>"; error jobs: the error text */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** Per-session usage aggregation (`GET /api/sessions/:id/stats`). */
|
||||
export interface SessionStats {
|
||||
/** count of persisted agent_end events */
|
||||
turns: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalCost: number;
|
||||
}
|
||||
|
||||
/** Daemon-wide usage totals (`GET /api/stats`). */
|
||||
export interface StatsTotals extends SessionStats {
|
||||
/** sessions with at least one agent_end event */
|
||||
sessionsCount: number;
|
||||
/** live agent WS connections */
|
||||
onlineCount: number;
|
||||
}
|
||||
|
||||
/** Repo → custom worker image registration row (GET /api/repos). */
|
||||
export interface RepoImage {
|
||||
repo: string;
|
||||
image: string;
|
||||
/** image present on the docker host (false → ops build needed) */
|
||||
built: boolean;
|
||||
}
|
||||
|
||||
export interface GitlabStatus {
|
||||
|
||||
+20
-1
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user