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