fix(review round 1): daemon PAT-header auth, job pruning, session-split batches, streaming tars, seed cleanup, ping/pong, byte caps, branch switch, slug --, ctx cancel, Init:true, pagination, latest/before events; web newest-window history + load-older, offline busy gate, staleness guards, memo store, auth probe, scroll key, focus-visible, draft restore, poll dedup; 106+181 tests, coverage 96.2%/95.1%+

This commit is contained in:
Raphael Westphal
2026-08-18 18:49:52 +02:00
parent 64e45e1a82
commit 6aac763563
25 changed files with 2564 additions and 959 deletions
+85 -82
View File
@@ -18,7 +18,10 @@ interface BoundaryState {
error: Error | null;
}
export class ErrorBoundary extends Component<{ children: ReactNode }, BoundaryState> {
export class ErrorBoundary extends Component<
{ children: ReactNode },
BoundaryState
> {
state: BoundaryState = { error: null };
static getDerivedStateFromError(error: Error): BoundaryState {
@@ -102,85 +105,85 @@ export default function App() {
const archived = store.sessions.filter((s) => !s.online).sort(byActivity);
return (
<div className="app-shell">
<nav
className={classNames("sidebar", sidebarOpen && "open")}
aria-label="Sessions"
>
<div className="sidebar-header">
<div className="brand">
<span className="logo" aria-hidden="true">
L
</span>
lvmh
</div>
<span className="conn-chip" title={`ws ${store.state}`}>
<span className={classNames("conn-dot", store.state)} />
{store.state}
</span>
</div>
<NavLink
to="/new"
className={({ isActive }) =>
classNames("new-chat-btn", isActive && "active")
}
<ErrorBoundary>
<div className="app-shell">
<nav
className={classNames("sidebar", sidebarOpen && "open")}
aria-label="Sessions"
>
+ Spawn session
</NavLink>
<div className="sidebar-sessions">
<SidebarSection
label="Active"
sessions={active}
emptyText="no active pi"
<div className="sidebar-header">
<div className="brand">
<span className="logo" aria-hidden="true">
L
</span>
lvmh
</div>
<span className="conn-chip" title={`ws ${store.state}`}>
<span className={classNames("conn-dot", store.state)} />
{store.state}
</span>
</div>
<NavLink
to="/new"
className={({ isActive }) =>
classNames("new-chat-btn", isActive && "active")
}
>
+ Spawn session
</NavLink>
<div className="sidebar-sessions">
<SidebarSection
label="Active"
sessions={active}
emptyText="no active pi"
/>
<SidebarSection
label="Archive"
sessions={archived}
emptyText="nothing archived"
/>
</div>
<div className="sidebar-footer">
<span className="spacer" />
<button
type="button"
className="icon-btn danger"
aria-label="Disconnect and clear settings"
onClick={() => {
clearSettings();
window.location.reload();
}}
>
disconnect
</button>
</div>
</nav>
{sidebarOpen && (
<div
className="sidebar-backdrop"
onClick={() => setSidebarOpen(false)}
/>
<SidebarSection
label="Archive"
sessions={archived}
emptyText="nothing archived"
/>
</div>
<div className="sidebar-footer">
<span className="spacer" />
)}
<main className="main">
{store.state !== "open" && (
<div className="conn-banner" role="status">
<span className={classNames("conn-dot", store.state)} />
connection {store.state} reconnecting
</div>
)}
<button
type="button"
className="icon-btn danger"
aria-label="Disconnect and clear settings"
onClick={() => {
clearSettings();
window.location.reload();
className="icon-btn menu-btn"
aria-label="Open menu"
style={{
display: "none",
padding: "8px 12px",
alignSelf: "flex-start",
}}
onClick={() => setSidebarOpen(true)}
>
disconnect
</button>
</div>
</nav>
{sidebarOpen && (
<div
className="sidebar-backdrop"
onClick={() => setSidebarOpen(false)}
/>
)}
<main className="main">
{store.state !== "open" && (
<div className="conn-banner" role="status">
<span className={classNames("conn-dot", store.state)} />
connection {store.state} reconnecting
</div>
)}
<button
type="button"
className="icon-btn menu-btn"
aria-label="Open menu"
style={{
display: "none",
padding: "8px 12px",
alignSelf: "flex-start",
}}
onClick={() => setSidebarOpen(true)}
>
</button>
<ErrorBoundary>
<Routes>
<Route
path="/"
@@ -202,15 +205,15 @@ export default function App() {
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</ErrorBoundary>
</main>
<div className="toasts" role="status">
{toasts.map((t) => (
<div key={t.id} className="toast">
{t.text}
</div>
))}
</main>
<div className="toasts" role="status">
{toasts.map((t) => (
<div key={t.id} className="toast">
{t.text}
</div>
))}
</div>
</div>
</div>
</ErrorBoundary>
);
}
+446 -299
View File
@@ -1,336 +1,483 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import type { ChatMessage, ToolState } from "./derive";
import ChatStream, { Bubble, TypingIndicator } from "./ChatStream";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Repo, SessionListItem } from "./protocol";
import { Route as ApiRoute } from "./protocol";
import { fetchJson } from "./api";
import SpawnView from "./SpawnView";
import type { SessionsStore } from "./store";
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
function msg(partial: Partial<ChatMessage>): ChatMessage {
const repo = (path: string, branch = "main"): Repo => ({
path,
name: path.split("/")[1] ?? path,
namespace: path.split("/")[0] ?? "g",
lastActivityAt: "2024-05-01T00:00:00Z",
webUrl: `https://gl/${path}`,
defaultBranch: branch,
});
const PUSH_TOAST = vi.fn();
function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
return {
key: `k-${Math.random()}`,
role: "assistant",
text: "",
thinking: null,
toolCalls: [],
toolCallId: null,
streaming: false,
...partial,
sessions: [],
state: "open",
spawnJobs: [],
refresh: async (): Promise<SessionListItem[]> =>
fetchJson<SessionListItem[]>(ApiRoute.Sessions),
subscribe: (): (() => void) => () => undefined,
...over,
};
}
const tool = (p: Partial<ToolState>): ToolState => ({
id: "c1",
name: "bash",
args: "",
running: false,
isError: false,
preview: "",
...p,
function tree(store: SessionsStore): React.ReactElement {
return (
<MemoryRouter initialEntries={["/new"]}>
<Routes>
<Route
path="/new"
element={<SpawnView store={store} pushToast={PUSH_TOAST} />}
/>
<Route path="/s/:id" element={<div data-testid="chat-route" />} />
</Routes>
</MemoryRouter>
);
}
/** Flush pending microtasks + React effects (works under fake timers). */
async function flush(ticks = 4): Promise<void> {
for (let i = 0; i < ticks; i += 1) {
// eslint-disable-next-line no-await-in-loop
await act(async () => {
await Promise.resolve();
});
}
}
beforeEach(() => {
PUSH_TOAST.mockClear();
seedSettings();
});
describe("Bubble", () => {
it("renders plain text per role class", () => {
const { container } = render(
<Bubble
msg={msg({ role: "user", text: "hi there" })}
tools={new Map()}
/>,
);
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
expect(container.textContent).toContain("hi there");
afterEach(() => {
vi.restoreAllMocks();
});
describe("SpawnView status", () => {
it("shows checking state, then error when gitlab status fails", async () => {
mockFetchJson((url) => {
if (url.includes("/api/gitlab/status"))
return jsonResponse({ error: "down" }, 500);
return [];
});
render(tree(makeStore()));
expect(screen.getByText("checking gitlab…")).toBeInTheDocument();
await flush();
expect(screen.getByText(/gitlab status failed: down/)).toBeInTheDocument();
});
});
describe("SpawnView connect flow", () => {
it("requires a token", async () => {
mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status"))
return { connected: false, baseUrl: "https://gl" };
return [];
});
render(tree(makeStore()));
await flush();
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await flush();
expect(screen.getByText("token required")).toBeInTheDocument();
});
it("renders empty assistant text as nothing but shows nothing when empty", () => {
const { container } = render(
<Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />,
);
expect(container.querySelector(".bubble")?.children).toHaveLength(0);
it("connects, clears the PAT, loads repos and shows the picker", async () => {
let connected = false;
mockFetchJson((url, init) => {
if (url.endsWith("/api/gitlab/status"))
return {
connected,
baseUrl: "https://gl",
username: connected ? "alice" : undefined,
};
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST") {
connected = true;
return { username: "alice" };
}
if (url.endsWith("/api/gitlab/repos"))
return [repo("g/one"), repo("g/two")];
return [];
});
render(tree(makeStore()));
await flush();
expect(screen.getByText("Connect GitLab")).toBeInTheDocument();
const pat = screen.getByLabelText(
"GitLab personal access token",
) as HTMLInputElement;
fireEvent.input(pat, { target: { value: "glpat-x" } });
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await flush();
expect(screen.getByText("g/one")).toBeInTheDocument();
expect(screen.getByText("g/two")).toBeInTheDocument();
expect(screen.queryByLabelText("GitLab personal access token")).toBeNull();
expect(screen.getByText("Repository")).toBeInTheDocument();
});
it("toolResult collapses to a one-line preview, expands to full text", async () => {
const long = `${"x".repeat(200)}`;
render(
<Bubble
msg={msg({ role: "toolResult", text: long })}
tools={new Map()}
/>,
);
const details = screen
.getByText("result")
.closest("details") as HTMLDetailsElement;
expect(details.open).toBe(false);
expect(details.textContent).toContain("…");
await userEvent.click(screen.getByText("result"));
expect(details.open).toBe(true);
expect(details.textContent).toContain(long);
it("connect failure shows the error and keeps the gate", async () => {
mockFetchJson((url, init) => {
if (url.endsWith("/api/gitlab/status"))
return { connected: false, baseUrl: "https://gl" };
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST")
return jsonResponse({ error: "bad pat" }, 401);
return [];
});
render(tree(makeStore()));
await flush();
fireEvent.input(screen.getByLabelText("GitLab personal access token"), {
target: { value: "glpat-bad" },
});
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await flush();
expect(screen.getByText("bad pat")).toBeInTheDocument();
expect(screen.getByText("Connect GitLab")).toBeInTheDocument();
});
it("toolResult with short text keeps full one-line preview", () => {
render(
<Bubble
msg={msg({ role: "toolResult", text: "short out" })}
tools={new Map()}
/>,
);
const details = screen
.getByText("result")
.closest("details") as HTMLDetailsElement;
expect(details.textContent).toContain("short out");
expect(details.textContent).not.toContain("…");
it("connected on load fetches repos immediately", async () => {
mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/quick")];
return [];
});
render(tree(makeStore()));
await flush();
expect(screen.getByText("g/quick")).toBeInTheDocument();
});
});
describe("SpawnView repo picker", () => {
function connectedMock(): void {
mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos"))
return [repo("g/alpha"), repo("g/beta", "dev")];
return [];
});
}
it("filters repos by query, keyboard selects, empty filter message", async () => {
connectedMock();
render(tree(makeStore()));
await flush();
expect(screen.getByText("g/alpha")).toBeInTheDocument();
const filter = screen.getByLabelText("Filter repositories");
fireEvent.input(filter, { target: { value: "beta" } });
expect(screen.queryByText("g/alpha")).toBeNull();
expect(screen.getByText("g/beta")).toBeInTheDocument();
const item = screen
.getByText("g/beta")
.closest(".repo-item") as HTMLElement;
fireEvent.keyDown(item, { key: "Enter" });
expect(screen.getByLabelText("Branch")).toHaveValue("dev");
fireEvent.input(filter, { target: { value: "zzz" } });
expect(screen.getByText("no matching repos")).toBeInTheDocument();
});
it("flattens whitespace in previews", () => {
render(
<Bubble
msg={msg({ role: "toolResult", text: "a\n\n b c" })}
tools={new Map()}
/>,
);
expect(screen.getAllByText("a b c").length).toBeGreaterThan(0);
it("shows loading repos while the list is pending", async () => {
const gate = { resolve: null as ((v: unknown) => void) | null };
mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos"))
return new Promise((res) => {
gate.resolve = res;
});
return [];
});
render(tree(makeStore()));
await flush();
expect(screen.getByText("loading repos…")).toBeInTheDocument();
gate.resolve?.([repo("g/late")]);
await flush();
expect(await screen.findByText("g/late")).toBeInTheDocument();
});
it("assistant tool calls attach tool cards", async () => {
const tools = new Map<string, ToolState>([
[
"c1",
tool({
id: "c1",
name: "bash",
args: "ls -la",
running: false,
isError: false,
preview: "file",
}),
it("spawn without selection shows pick-a-repo error", async () => {
connectedMock();
render(tree(makeStore()));
await flush();
expect(screen.getByLabelText("Spawn container")).toBeDisabled();
expect(screen.getByText("select a repo above")).toBeInTheDocument();
});
});
describe("SpawnView spawn+poll", () => {
beforeEach(() => {
vi.useFakeTimers();
});
it("spawns, polls until online, then navigates to the chat", async () => {
const posts: Array<[string, RequestInit | undefined]> = [];
let sessionsOnline = false; // flipped after the first poll tick
const fetchMock = mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
posts.push([url, init]);
return { sessionId: "new-1", containerId: "abc123def456" };
}
if (url.endsWith("/api/sessions"))
return sessionsOnline
? [
{
id: "new-1",
name: "spawned",
cwd: "/w",
model: "m",
provider: "p",
agent: true,
repo: "g/proj",
startedAt: 1,
online: true,
lastEventAt: 1,
},
]
: [];
if (url.endsWith("/api/spawn/status")) return [];
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/proj")];
return [];
});
const store = makeStore();
const { unmount } = render(tree(store));
await flush();
fireEvent.click(screen.getByText("g/proj"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(posts).toHaveLength(1);
expect((posts[0]?.[1] as RequestInit).body).toBe(
JSON.stringify({ repo: "g/proj", branch: "main" }),
);
expect(screen.getByText("Spawning…")).toBeInTheDocument();
expect(screen.getByText(/container abc123def456/)).toBeInTheDocument();
expect(
screen.getByText("waiting for session to come online…"),
).toBeInTheDocument();
// first tick: session not online yet
await act(async () => {
vi.advanceTimersByTime(1500);
});
await flush();
expect(screen.queryByTestId("chat-route")).toBeNull();
// each poll tick issues exactly one sessions fetch (refresh reuse, S7)
const sessionsFetches = fetchMock.mock.calls.filter(([u]) =>
String(u).endsWith("/api/sessions"),
).length;
expect(sessionsFetches).toBe(1);
// session comes online -> next tick navigates
sessionsOnline = true;
await act(async () => {
vi.advanceTimersByTime(1500);
});
await flush();
expect(screen.getByTestId("chat-route")).toBeInTheDocument();
unmount();
});
it("spawning view shows job line from status and spawn error text", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sx", containerId: "" };
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
return [];
});
const store = makeStore({
spawnJobs: [
{ sessionId: "sx", repo: "g/p", state: "cloning", containerId: "" },
],
]);
render(
<Bubble
msg={msg({
role: "assistant",
text: "finished",
toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }],
})}
tools={tools}
/>,
);
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
expect(screen.getByText("finished")).toBeInTheDocument();
const summary = screen
.getByText("🛠 bash")
.closest("summary") as HTMLElement;
const card = summary.closest("details") as HTMLDetailsElement;
expect(card.open).toBe(false);
await userEvent.click(summary);
expect(card.open).toBe(true);
expect(card.textContent).toContain("ls -la");
expect(card.textContent).toContain("file");
});
render(tree(store));
await flush();
fireEvent.click(screen.getByText("g/p"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(screen.getByText("Spawning…")).toBeInTheDocument();
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
});
it("tool card status variants: running, error, done", () => {
const tools = new Map<string, ToolState>([
["c1", tool({ id: "c1", running: true })],
["c2", tool({ id: "c2", running: false, isError: true })],
["c3", tool({ id: "c3", running: false, isError: false })],
]);
render(
<Bubble
msg={msg({
role: "assistant",
toolCalls: ["c1", "c2", "c3"].map((id) => ({
id,
name: `t-${id}`,
argsJson: "{}",
})),
})}
tools={tools}
/>,
);
expect(screen.getByText("working…")).toBeInTheDocument();
expect(screen.getByText("error")).toBeInTheDocument();
expect(screen.getByText("done")).toBeInTheDocument();
it("branch left blank sends repo only; poll refresh failure toasts", async () => {
const bodies: string[] = [];
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
bodies.push(String(init.body));
return { sessionId: "new-2", containerId: "cccccccccccc" };
}
if (url.endsWith("/api/spawn/status")) return [];
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/blank")];
return [];
});
const failingRefresh = makeStore({
refresh: (): Promise<SessionListItem[]> =>
Promise.reject(new Error("boom")),
});
render(tree(failingRefresh));
await flush();
fireEvent.click(screen.getByText("g/blank"));
fireEvent.change(screen.getByLabelText("Branch"), {
target: { value: "" },
});
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(bodies).toEqual([JSON.stringify({ repo: "g/blank" })]);
await act(async () => {
vi.advanceTimersByTime(1500);
});
await flush();
expect(PUSH_TOAST).toHaveBeenCalledWith("boom");
});
it("tool call with no matching state renders no card", () => {
const { container } = render(
<Bubble
msg={msg({
role: "assistant",
toolCalls: [{ id: "ghost", name: "x", argsJson: "{}" }],
})}
tools={new Map()}
/>,
);
expect(container.querySelectorAll(".tool-card")).toHaveLength(0);
it("spawn POST failure shows the error", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return jsonResponse({ error: "no docker" }, 500);
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/x")];
return [];
});
render(tree(makeStore()));
await flush();
fireEvent.click(screen.getByText("g/x"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(screen.getByText("no docker")).toBeInTheDocument();
});
it("thinking block only for non-empty thinking", async () => {
render(<Bubble msg={msg({ thinking: "because" })} tools={new Map()} />);
const details = screen
.getByText("thinking")
.closest("details") as HTMLDetailsElement;
await userEvent.click(screen.getByText("thinking"));
expect(details.open).toBe(true);
expect(details.textContent).toContain("because");
it("polling gives up after the tick cap and stays on the page", async () => {
let statusCalls = 0;
mockFetchJson((url) => {
if (url.endsWith("/api/spawn/status")) {
statusCalls += 1;
return [];
}
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/slow")];
if (url.endsWith("/api/spawn"))
return { sessionId: "slow-1", containerId: "d" };
return [];
});
const { unmount } = render(tree(makeStore()));
await flush();
fireEvent.click(screen.getByText("g/slow"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(screen.getByText("Spawning…")).toBeInTheDocument();
const { container } = render(
<Bubble msg={msg({ thinking: null })} tools={new Map()} />,
);
expect(container.querySelector(".thinking")).toBeNull();
await act(async () => {
vi.advanceTimersByTime(1500 * 402);
});
await flush();
const afterCap = statusCalls;
await act(async () => {
vi.advanceTimersByTime(1500 * 10);
});
await flush();
expect(statusCalls).toBe(afterCap);
expect(screen.getByText("Spawning…")).toBeInTheDocument();
unmount();
});
it("streaming bubble shows the caret", () => {
const { container } = render(
<Bubble msg={msg({ text: "par", streaming: true })} tools={new Map()} />,
);
expect(container.querySelector(".stream-caret")).not.toBeNull();
it("spawn job line renders from store.spawnJobs", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sj-1", containerId: "cid" };
if (url.endsWith("/api/spawn/status")) return [];
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
return [];
});
const store = makeStore();
const { rerender } = render(tree(store));
await flush();
fireEvent.click(screen.getByText("g/p"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(screen.getByText("Spawning…")).toBeInTheDocument();
store.spawnJobs = [{ repo: "g/p", state: "cloning", sessionId: "sj-1" }];
act(() => {
rerender(tree(store));
});
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
});
});
describe("TypingIndicator", () => {
it("renders three dots with aria-live", () => {
const { container } = render(<TypingIndicator />);
expect(container.querySelector('[aria-live="polite"]')).not.toBeNull();
expect(container.querySelectorAll(".dot")).toHaveLength(3);
});
});
describe("ChatStream", () => {
it("renders messages and typing indicator while busy with no open stream", () => {
const { container, rerender } = render(
<ChatStream
messages={[msg({ key: "a", text: "one" })]}
tools={new Map()}
busy
/>,
);
expect(container.querySelector(".typing")).not.toBeNull();
rerender(
<ChatStream
messages={[
msg({ key: "a", text: "one" }),
msg({ key: "b", streaming: true }),
]}
tools={new Map()}
busy
/>,
);
expect(container.querySelector(".typing")).toBeNull();
rerender(
<ChatStream
messages={[msg({ key: "a", text: "one" })]}
tools={new Map()}
busy={false}
/>,
);
expect(container.querySelector(".typing")).toBeNull();
});
it("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => {
const { container, rerender } = render(
<ChatStream
messages={[msg({ key: "a" })]}
tools={new Map()}
busy={false}
/>,
);
const scroller = container.querySelector(".chat-scroll") as HTMLElement;
Object.defineProperty(scroller, "scrollHeight", {
configurable: true,
value: 1000,
});
Object.defineProperty(scroller, "clientHeight", {
configurable: true,
value: 300,
describe("SpawnSteps", () => {
it("renders progress steps matching job state", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sp1", containerId: "" };
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
return [];
});
scroller.dispatchEvent(new Event("scroll"));
// pinned: scrollTop at bottom
scroller.scrollTop = 700;
Object.defineProperty(scroller, "scrollTop", {
configurable: true,
writable: true,
value: 700,
const store = makeStore({
spawnJobs: [
{ sessionId: "sp1", repo: "g/p", state: "building", containerId: "" },
],
});
scroller.dispatchEvent(new Event("scroll"));
rerender(
<ChatStream
messages={[msg({ key: "a" }), msg({ key: "b" })]}
tools={new Map()}
busy={false}
/>,
);
expect(scroller.scrollTop).toBe(1000);
render(tree(store));
await flush();
fireEvent.click(screen.getByText("g/p"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
const steps = document.querySelectorAll(".spawn-progress .step");
expect(steps).toHaveLength(4);
expect(steps[0]?.className).toContain("done");
expect(steps[1]?.className).toContain("current");
expect(steps[2]?.className).toBe("step");
});
// scroll far up -> unpin
Object.defineProperty(scroller, "scrollTop", {
configurable: true,
writable: true,
value: 0,
it("progress element carries progressbar semantics for the current step", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sp2", containerId: "" };
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
return [];
});
scroller.dispatchEvent(new Event("scroll"));
rerender(
<ChatStream
messages={[msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" })]}
tools={new Map()}
busy={false}
/>,
);
expect(scroller.scrollTop).toBe(0);
// scroll near bottom (within 80px) -> pinned again
Object.defineProperty(scroller, "scrollTop", {
configurable: true,
writable: true,
value: 940,
const store = makeStore({
spawnJobs: [
{ sessionId: "sp2", repo: "g/p", state: "building", containerId: "" },
],
});
scroller.dispatchEvent(new Event("scroll"));
rerender(
<ChatStream
messages={[
msg({ key: "a" }),
msg({ key: "b" }),
msg({ key: "c" }),
msg({ key: "d" }),
]}
tools={new Map()}
busy={false}
/>,
);
expect(scroller.scrollTop).toBe(1000);
});
});
describe("copy button", () => {
it("copy button writes message text and flashes copied", async () => {
const writeText = vi.fn(() => Promise.resolve());
Object.assign(navigator, { clipboard: { writeText } });
render(
<Bubble
msg={{ key: "k", role: "assistant", text: "copy me", thinking: null, toolCalls: [], toolCallId: null, streaming: false }}
tools={new Map()}
/>,
);
const btn = screen.getByRole("button", { name: "Copy message" });
fireEvent.click(btn);
expect(writeText).toHaveBeenCalledWith("copy me");
await waitFor(() => expect(screen.getByText("copied")).toBeInTheDocument());
});
it("user bubbles get a copy button, toolResults do not", () => {
const { rerender } = render(
<Bubble
msg={{ key: "u", role: "user", text: "hi", thinking: null, toolCalls: [], toolCallId: null, streaming: false }}
tools={new Map()}
/>,
);
expect(screen.getByRole("button", { name: "Copy message" })).toBeInTheDocument();
rerender(
<Bubble
msg={{ key: "t", role: "toolResult", text: "r", thinking: null, toolCalls: [], toolCallId: "c1", streaming: false }}
tools={new Map()}
/>,
);
expect(screen.queryByRole("button", { name: "Copy message" })).toBeNull();
render(tree(store));
await flush();
fireEvent.click(screen.getByText("g/p"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
const bar = screen.getByRole("progressbar");
expect(bar).toHaveAttribute("aria-valuemin", "1");
expect(bar).toHaveAttribute("aria-valuemax", "4");
expect(bar).toHaveAttribute("aria-valuenow", "2"); // building = step 2
});
});
+25 -1
View File
@@ -136,9 +136,20 @@ interface Props {
messages: ChatMessage[];
tools: Map<string, ToolState>;
busy: boolean;
/** an older page exists beyond the loaded window (B1) */
hasOlder: boolean;
loadingOlder: boolean;
onLoadOlder: () => void;
}
export default function ChatStream({ messages, tools, busy }: Props) {
export default function ChatStream({
messages,
tools,
busy,
hasOlder,
loadingOlder,
onLoadOlder,
}: Props) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const pinnedRef = useRef<boolean>(true);
@@ -166,6 +177,19 @@ export default function ChatStream({ messages, tools, busy }: Props) {
return (
<div className="chat-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="chat-inner">
{hasOlder && (
<div className="load-older">
<button
type="button"
className="btn-secondary"
aria-label="Load older messages"
disabled={loadingOlder}
onClick={onLoadOlder}
>
{loadingOlder ? "loading…" : "Load older"}
</button>
</div>
)}
{messages.map((m) => (
<Bubble key={m.key} msg={m} tools={tools} />
))}
+336 -6
View File
@@ -7,7 +7,7 @@ import {
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactElement } from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { MemoryRouter, Route, Routes, useNavigate } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { EventFrame } from "./protocol";
import { ApiError } from "./api";
@@ -42,7 +42,7 @@ function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
sessions,
state: "open",
spawnJobs: [],
refresh: async () => undefined,
refresh: async () => [],
subscribe: (
sessionId: string,
onEvents: (events: EventFrame[]) => void,
@@ -131,6 +131,33 @@ function historyEvents(): EventFrame[] {
];
}
/** seq from..to inclusive, one user message per seq. */
function pageEvents(
from: number,
to: number,
text: (n: number) => string,
): EventFrame[] {
const out: EventFrame[] = [];
for (let n = from; n <= to; n += 1) {
out.push({
v: 1,
sessionId: "s1",
seq: n,
ts: 0,
type: "message_end",
message: {
role: "user",
id: `u${n}`,
text: text(n),
thinking: null,
toolCalls: [],
toolCallId: null,
},
});
}
return out;
}
beforeEach(() => {
seq = 0;
currentSub = null;
@@ -362,7 +389,8 @@ describe("ChatView", () => {
</MemoryRouter>,
);
await screen.findByText("caught up");
expect(after).toBe("0");
// initial load is latest=1: no after= cursor has been issued yet
expect(after).toBe("");
// reconnect: state closed -> open triggers the after=N refetch
act(() => {
@@ -418,10 +446,174 @@ describe("ChatView", () => {
});
});
describe("ChatView history pagination (B1)", () => {
it("initial history fetch requests the newest page via latest=1", async () => {
const eventsUrls: string[] = [];
mockFetchJson((url) => {
if (url.includes("/events")) {
eventsUrls.push(url);
return historyEvents();
}
return [];
});
renderChat(makeStore());
await screen.findByText("hello there");
await waitFor(() => expect(eventsUrls.length).toBeGreaterThan(0));
expect(eventsUrls[0]).toContain("latest=1");
expect(eventsUrls[0]).toContain("limit=1000");
expect(eventsUrls[0]).not.toContain("after=");
});
it("short first page renders no Load older button", async () => {
mockFetchJson((url) => (url.includes("/events") ? historyEvents() : []));
renderChat(makeStore());
await screen.findByText("hello there");
expect(
screen.queryByRole("button", { name: "Load older messages" }),
).toBeNull();
});
it("Load older prepends the previous page and hides at a short page", async () => {
const olderUrls: string[] = [];
mockFetchJson((url) => {
if (url.includes("/events")) {
if (url.includes("before=")) {
olderUrls.push(url);
return pageEvents(1, 2, (n) => `oldest-${n}`);
}
return pageEvents(3, 1002, (n) => `m${n}`);
}
return [];
});
renderChat(makeStore());
await screen.findByText("m1002");
expect(
screen.getByRole("button", { name: "Load older messages" }),
).toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", { name: "Load older messages" }),
);
await screen.findByText("oldest-1");
expect(screen.getByText("m1002")).toBeInTheDocument();
expect(olderUrls).toHaveLength(1);
expect(olderUrls[0]).toContain("before=3");
expect(olderUrls[0]).toContain("limit=1000");
// short page (2 < 1000): the button disappears
await waitFor(() =>
expect(
screen.queryByRole("button", { name: "Load older messages" }),
).toBeNull(),
);
});
it("cursor refetch after loading older pages still uses the max seq", async () => {
let after = "";
mockFetchJson((url) => {
const m = /[?&]after=(\d+)/.exec(url);
if (m !== null) after = m[1] ?? "";
if (url.includes("/events")) {
if (url.includes("before="))
return pageEvents(1, 2, (n) => `oldest-${n}`);
return pageEvents(3, 1002, (n) => `m${n}`);
}
return [];
});
const { rerender } = renderChat(makeStore());
await screen.findByText("m1002");
await userEvent.click(
screen.getByRole("button", { name: "Load older messages" }),
);
await screen.findByText("oldest-1");
// reconnect: cursor must still point at the newest seq (1002), not at the
// older batch's max (2) — older pages must not poison lastSeqRef
rerenderChatAgain(rerender, makeStore({ state: "connecting" }));
rerenderChatAgain(rerender, makeStore({ state: "open" }));
await vi.waitFor(() => expect(after).toBe("1002"));
});
it("Load older failure toasts and keeps the button", async () => {
mockFetchJson((url) => {
if (url.includes("before="))
return jsonResponse({ error: "older fail" }, 500);
if (url.includes("/events")) return pageEvents(3, 1002, (n) => `m${n}`);
return [];
});
renderChat(makeStore());
await screen.findByText("m1002");
await userEvent.click(
screen.getByRole("button", { name: "Load older messages" }),
);
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("older fail"),
);
expect(
screen.getByRole("button", { name: "Load older messages" }),
).toBeEnabled();
});
it("a second click while a page load is in flight is ignored", async () => {
let releaseOlder: ((v: EventFrame[]) => void) | null = null;
let beforeCalls = 0;
mockFetchJson((url) => {
if (url.includes("/events")) {
if (url.includes("before=")) {
beforeCalls += 1;
return new Promise<EventFrame[]>((res) => {
releaseOlder = res;
});
}
return pageEvents(3, 1002, (n) => `m${n}`);
}
return [];
});
renderChat(makeStore());
await screen.findByText("m1002");
const btn = screen.getByRole("button", { name: "Load older messages" });
await userEvent.click(btn);
// still pending: the button is disabled and re-entry is a no-op
expect(btn).toBeDisabled();
fireEvent.click(btn);
expect(beforeCalls).toBe(1);
await act(async () => {
releaseOlder?.(pageEvents(1, 2, (n) => `oldest-${n}`));
});
await screen.findByText("oldest-1");
expect(beforeCalls).toBe(1);
});
});
describe("ChatView busy vs offline (B2)", () => {
it("offline session with history ending at agent_start shows no typing and the send button", async () => {
mockFetchJson((url) =>
url.includes("/events") ? [ev("agent_start")] : [],
);
const offline = [{ ...sessions[0]!, online: false }];
const { container } = renderChat(makeStore({ sessions: offline }));
await waitFor(() => expect(currentSub).not.toBeNull());
await vi.waitFor(() =>
expect(container.querySelector(".typing")).toBeNull(),
);
expect(screen.queryByLabelText("Abort current run")).toBeNull();
expect(screen.getByLabelText("Send message")).toBeInTheDocument();
});
it("online session with history ending at agent_start still shows the stop button", async () => {
mockFetchJson((url) =>
url.includes("/events") ? [ev("agent_start")] : [],
);
renderChat(makeStore());
await waitFor(() => expect(currentSub).not.toBeNull());
expect(screen.getByLabelText("Abort current run")).toBeInTheDocument();
expect(screen.queryByLabelText("Send message")).toBeNull();
});
});
describe("ChatView close pi", () => {
it("close button deletes the container, toasts and refreshes (agent session)", async () => {
const agent = [{ ...sessions[0]!, id: "s1", agent: true }];
const refresh = vi.fn(async () => undefined);
const refresh = vi.fn(async () => []);
const deletes: string[] = [];
mockFetchJson((url, init) => {
if (init?.method === "DELETE" && url.includes("/container")) {
@@ -485,6 +677,141 @@ describe("ChatView refetch failure", () => {
});
});
describe("ChatView stale async guards (S1/S4) and send draft (S6)", () => {
// same-tree navigation: ChatView stays mounted while the session id changes
function Switcher({
state,
}: {
state: "connecting" | "open";
}): 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({ state })} pushToast={pushToast} />
}
/>
<Route path="*" element={<div>OTHER</div>} />
</Routes>
</>
);
}
it("refetch resolving after a session switch does not merge stale events (S1)", async () => {
let releaseRefetch: ((v: EventFrame[]) => void) | null = null;
const staleFrame: EventFrame = {
v: 1,
sessionId: "s1",
seq: 77,
ts: 0,
type: "message_end",
message: {
role: "user",
id: "u77",
text: "stale s1 frame",
thinking: null,
toolCalls: [],
toolCallId: null,
},
};
let ghostAfter = "unfetched";
mockFetchJson((url) => {
if (url.startsWith("http://srv/api/sessions/ghost/events")) {
const m = /[?&]after=(\d+)/.exec(url);
if (m !== null) ghostAfter = m[1] ?? "";
return [];
}
if (url.startsWith("http://srv/api/sessions/s1/events")) {
if (url.includes("after="))
return new Promise<EventFrame[]>((res) => {
releaseRefetch = res;
});
return [];
}
return [];
});
const { rerender } = render(
<MemoryRouter initialEntries={["/s/s1"]}>
<Switcher state="connecting" />
</MemoryRouter>,
);
// let the initial history load settle (loadedRef) before the ws opens
await act(async () => {
await Promise.resolve();
});
// ws (re)opens so the after=N refetch fires for s1
rerender(
<MemoryRouter initialEntries={["/s/s1"]}>
<Switcher state="open" />
</MemoryRouter>,
);
await waitFor(() => expect(releaseRefetch).not.toBeNull());
// navigate away to ghost while the s1 refetch is in flight
await userEvent.click(
screen.getByRole("button", { name: "switch session" }),
);
await screen.findByText("ghost");
// ghost reconnects: its own cursor refetch must still start at 0 — the
// stale s1 batch resolving concurrently must not poison it
rerender(
<MemoryRouter initialEntries={["/s/ghost"]}>
<Switcher state="connecting" />
</MemoryRouter>,
);
rerender(
<MemoryRouter initialEntries={["/s/ghost"]}>
<Switcher state="open" />
</MemoryRouter>,
);
await waitFor(() => expect(ghostAfter).toBe("0"));
await act(async () => {
releaseRefetch?.([staleFrame]);
});
expect(screen.queryByText("stale s1 frame")).toBeNull();
});
it("ChatStream remounts on session switch (scroll pin reset, S4)", async () => {
mockFetchJson(() => []);
render(
<MemoryRouter initialEntries={["/s/s1"]}>
<Switcher state="open" />
</MemoryRouter>,
);
await screen.findByRole("button", { name: "switch session" });
const firstScroller = document.querySelector(".chat-scroll");
expect(firstScroller).not.toBeNull();
await userEvent.click(
screen.getByRole("button", { name: "switch session" }),
);
await screen.findByText("ghost");
expect(document.querySelector(".chat-scroll")).not.toBe(firstScroller);
});
it("failed send restores the draft (S6)", async () => {
mockFetchJson((_url, init) =>
init?.method === "POST" ? jsonResponse({ error: "nope" }, 500) : [],
);
renderChat(makeStore());
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
await userEvent.type(ta, "precious draft");
await userEvent.click(screen.getByLabelText("Send message"));
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("nope"));
expect(ta.value).toBe("precious draft");
});
});
describe("ChatView unknown session", () => {
it("unknown id falls back to id title and hides agent-only controls", async () => {
mockFetchJson((url) => {
@@ -505,7 +832,9 @@ describe("ChatView usage chip", () => {
seq = 0;
return [
...historyEvents(),
ev("agent_end", { usage: { inputTokens: 1200, outputTokens: 340, totalCost: 0.02 } }),
ev("agent_end", {
usage: { inputTokens: 1200, outputTokens: 340, totalCost: 0.02 },
}),
];
}
return [];
@@ -518,7 +847,8 @@ describe("ChatView usage chip", () => {
it("usage chip hidden when no usage seen", async () => {
mockFetchJson((url) => {
if (url.startsWith("http://srv/api/sessions/s1/events")) return historyEvents();
if (url.startsWith("http://srv/api/sessions/s1/events"))
return historyEvents();
return [];
});
renderChat(makeStore());
+53 -7
View File
@@ -15,6 +15,7 @@ import ChatStream from "./ChatStream";
import TaskPanel from "./TaskPanel";
const HISTORY_LIMIT: number = 1000;
const LATEST_QUERY: string = "latest=1";
const TEXTAREA_MAX_H: number = 200;
const SEND_KEY: string = "Enter";
@@ -32,10 +33,15 @@ export default function ChatView({ store, pushToast }: Props) {
const [draft, setDraft] = useState<string>("");
const [sending, setSending] = useState<boolean>(false);
const [tasksOpen, setTasksOpen] = useState<boolean>(false);
const [hasOlder, setHasOlder] = useState<boolean>(false);
const [loadingOlder, setLoadingOlder] = useState<boolean>(false);
const lastSeqRef = useRef<number>(0);
const loadedRef = useRef<boolean>(false);
const taRef = useRef<HTMLTextAreaElement | null>(null);
// guards async fetches against session switches (S1)
const sessionIdRef = useRef<string>(sessionId);
sessionIdRef.current = sessionId;
const session = store.sessions.find((s) => s.id === sessionId);
@@ -62,21 +68,23 @@ export default function ChatView({ store, pushToast }: Props) {
});
}, []);
// history load on mount / session switch
// history load on mount / session switch: newest page, ascending (B1)
useEffect(() => {
if (sessionId.length === 0) return;
loadedRef.current = false;
setLoadError("");
setEvents([]);
setHasOlder(false);
lastSeqRef.current = 0;
let alive = true;
void (async () => {
try {
const evts = await fetchJson<EventFrame[]>(
`${Route.SessionEvents(sessionId)}?after=0&limit=${HISTORY_LIMIT}`,
`${Route.SessionEvents(sessionId)}?${LATEST_QUERY}&limit=${HISTORY_LIMIT}`,
);
if (!alive) return;
applyEvents(evts);
setHasOlder(evts.length >= HISTORY_LIMIT);
} catch (err) {
if (alive) setLoadError(errMessage(err));
} finally {
@@ -94,20 +102,52 @@ export default function ChatView({ store, pushToast }: Props) {
return store.subscribe(sessionId, applyEvents);
}, [sessionId, store.state, store, applyEvents]);
// missed events after reconnect (persisted only)
// missed events after reconnect (persisted only); a response for a
// previous session must not merge here nor touch the cursor (S1)
useEffect(() => {
if (store.state !== "open" || !loadedRef.current) return;
const id: string = sessionId;
const after: number = lastSeqRef.current;
void fetchJson<EventFrame[]>(
`${Route.SessionEvents(sessionId)}?after=${after}&limit=${HISTORY_LIMIT}`,
`${Route.SessionEvents(id)}?after=${after}&limit=${HISTORY_LIMIT}`,
)
.then(applyEvents)
.then((evts) => {
if (sessionIdRef.current === id) applyEvents(evts);
})
.catch(() => undefined);
}, [store.state, sessionId, applyEvents]);
const chat = useMemo(() => deriveChat(events), [events]);
const tasks = useMemo(() => deriveTasks(events), [events]);
// a closed/crashed container can never emit agent_settled: an offline
// session must never look busy (B2)
const busy: boolean = chat.busy && session?.online !== false;
const minSeq: number = useMemo(
() => (events.length === 0 ? 0 : Math.min(...events.map((e) => e.seq))),
[events],
);
// previous page (seq < oldest loaded), ascending (B1)
const loadOlder = useCallback(async (): Promise<void> => {
// the button only renders while a full page is loaded, so minSeq > 0
if (sessionIdRef.current !== sessionId || loadingOlder) return;
setLoadingOlder(true);
try {
const evts = await fetchJson<EventFrame[]>(
`${Route.SessionEvents(sessionId)}?before=${minSeq}&limit=${HISTORY_LIMIT}`,
);
if (sessionIdRef.current !== sessionId) return;
applyEvents(evts);
setHasOlder(evts.length >= HISTORY_LIMIT);
} catch (err) {
pushToast(errMessage(err));
} finally {
setLoadingOlder(false);
}
}, [sessionId, minSeq, loadingOlder, applyEvents, pushToast]);
const autosize = useCallback((): void => {
const el = taRef.current;
if (el === null) return;
@@ -128,6 +168,8 @@ export default function ChatView({ store, pushToast }: Props) {
body: JSON.stringify({ message: text }),
});
} catch (err) {
// the message never reached the session: put it back (S6)
setDraft(text);
if (err instanceof ApiError && err.status === 409)
pushToast("session offline");
else pushToast(errMessage(err));
@@ -215,9 +257,13 @@ export default function ChatView({ store, pushToast }: Props) {
</div>
) : (
<ChatStream
key={sessionId}
messages={chat.messages}
tools={chat.tools}
busy={chat.busy}
busy={busy}
hasOlder={hasOlder}
loadingOlder={loadingOlder}
onLoadOlder={() => void loadOlder()}
/>
)}
<div className="composer">
@@ -231,7 +277,7 @@ export default function ChatView({ store, pushToast }: Props) {
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
/>
{chat.busy ? (
{busy ? (
<button
type="button"
className="abort-btn"
+51 -7
View File
@@ -61,20 +61,22 @@ describe("SessionsView", () => {
it("empty state message (after load window)", () => {
vi.useFakeTimers();
renderView({ sessions: [] });
act(() => { vi.advanceTimersByTime(700); });
act(() => {
vi.advanceTimersByTime(700);
});
expect(screen.getByText(/No active sessions/i)).toBeInTheDocument();
vi.useRealTimers();
});
it("skeleton while first load pending", () => {
renderView({ sessions: [] });
expect(screen.getByRole("heading", { name: "Active sessions" })).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Active sessions" }),
).toBeInTheDocument();
const skel = document.querySelector(".skeleton");
expect(skel).not.toBeNull();
});
it("renders cards sorted by last activity with fallbacks", () => {
renderView({
sessions: [
@@ -181,12 +183,54 @@ describe("SessionsView", () => {
</Routes>
</MemoryRouter>,
);
fireEvent.keyDown(screen.getByRole("button", { name: "Open session s8" }), {
key: " ",
});
const ev = fireEvent.keyDown(
screen.getByRole("button", { name: "Open session s8" }),
{
key: " ",
},
);
expect(ev).toBe(false); // preventDefault consumed the default action
expect(probe).toHaveBeenCalledWith("/s/s8");
});
it("Space and Enter keydown are default-prevented so the page does not scroll (S8)", () => {
const probe = vi.fn();
const renderKb = (): HTMLElement => {
render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route
path="/"
element={
<SessionsView
sessions={[session({ id: "kb2" })]}
onChanged={() => undefined}
pushToast={() => undefined}
/>
}
/>
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
</Routes>
</MemoryRouter>,
);
return screen.getByRole("button", { name: "Open session kb2" });
};
// document-level bubble listener observes the event AFTER React's
// delegated handler, so defaultPrevented reflects the component's call
const seen: boolean[] = [];
const rec = (e: Event): void => {
seen.push(e.defaultPrevented);
};
document.addEventListener("keydown", rec);
// Space: navigation unmounts the card, so each key gets a fresh render
fireEvent.keyDown(renderKb(), { key: " " });
fireEvent.keyDown(renderKb(), { key: "Enter" });
fireEvent.keyDown(renderKb(), { key: "Tab" });
document.removeEventListener("keydown", rec);
expect(seen).toEqual([true, true, false]);
expect(probe).toHaveBeenCalledWith("/s/kb2");
});
it("stop with unnamed session toasts the id", async () => {
seedSettings();
mockFetchJson(() => ({ ok: true }));
+5 -1
View File
@@ -28,7 +28,11 @@ function SessionCard({
aria-label={`Open session ${s.name ?? s.id}`}
onClick={() => onOpen(s.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onOpen(s.id);
if (e.key === "Enter" || e.key === " ") {
// Space/Enter on a role=button must not scroll the page
e.preventDefault();
onOpen(s.id);
}
}}
>
<span
+373 -421
View File
@@ -1,448 +1,400 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Repo } from "./protocol";
import SpawnView from "./SpawnView";
import type { SessionsStore } from "./store";
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import type { ChatMessage, ToolState } from "./derive";
import ChatStream, { Bubble, TypingIndicator } from "./ChatStream";
const repo = (path: string, branch = "main"): Repo => ({
path,
name: path.split("/")[1] ?? path,
namespace: path.split("/")[0] ?? "g",
lastActivityAt: "2024-05-01T00:00:00Z",
webUrl: `https://gl/${path}`,
defaultBranch: branch,
});
const PUSH_TOAST = vi.fn();
function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
function msg(partial: Partial<ChatMessage>): ChatMessage {
return {
sessions: [],
state: "open",
spawnJobs: [],
refresh: async (): Promise<void> => undefined,
subscribe: (): (() => void) => () => undefined,
...over,
key: `k-${Math.random()}`,
role: "assistant",
text: "",
thinking: null,
toolCalls: [],
toolCallId: null,
streaming: false,
...partial,
};
}
function tree(store: SessionsStore): React.ReactElement {
return (
<MemoryRouter initialEntries={["/new"]}>
<Routes>
<Route
path="/new"
element={<SpawnView store={store} pushToast={PUSH_TOAST} />}
/>
<Route path="/s/:id" element={<div data-testid="chat-route" />} />
</Routes>
</MemoryRouter>
);
}
/** Flush pending microtasks + React effects (works under fake timers). */
async function flush(ticks = 4): Promise<void> {
for (let i = 0; i < ticks; i += 1) {
// eslint-disable-next-line no-await-in-loop
await act(async () => {
await Promise.resolve();
});
}
}
beforeEach(() => {
PUSH_TOAST.mockClear();
seedSettings();
const tool = (p: Partial<ToolState>): ToolState => ({
id: "c1",
name: "bash",
args: "",
running: false,
isError: false,
preview: "",
...p,
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("SpawnView status", () => {
it("shows checking state, then error when gitlab status fails", async () => {
mockFetchJson((url) => {
if (url.includes("/api/gitlab/status"))
return jsonResponse({ error: "down" }, 500);
return [];
});
render(tree(makeStore()));
expect(screen.getByText("checking gitlab…")).toBeInTheDocument();
await flush();
expect(screen.getByText(/gitlab status failed: down/)).toBeInTheDocument();
});
});
describe("SpawnView connect flow", () => {
it("requires a token", async () => {
mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status"))
return { connected: false, baseUrl: "https://gl" };
return [];
});
render(tree(makeStore()));
await flush();
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await flush();
expect(screen.getByText("token required")).toBeInTheDocument();
});
it("connects, clears the PAT, loads repos and shows the picker", async () => {
let connected = false;
mockFetchJson((url, init) => {
if (url.endsWith("/api/gitlab/status"))
return {
connected,
baseUrl: "https://gl",
username: connected ? "alice" : undefined,
};
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST") {
connected = true;
return { username: "alice" };
}
if (url.endsWith("/api/gitlab/repos"))
return [repo("g/one"), repo("g/two")];
return [];
});
render(tree(makeStore()));
await flush();
expect(screen.getByText("Connect GitLab")).toBeInTheDocument();
const pat = screen.getByLabelText(
"GitLab personal access token",
) as HTMLInputElement;
fireEvent.input(pat, { target: { value: "glpat-x" } });
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await flush();
expect(screen.getByText("g/one")).toBeInTheDocument();
expect(screen.getByText("g/two")).toBeInTheDocument();
expect(screen.queryByLabelText("GitLab personal access token")).toBeNull();
expect(screen.getByText("Repository")).toBeInTheDocument();
});
it("connect failure shows the error and keeps the gate", async () => {
mockFetchJson((url, init) => {
if (url.endsWith("/api/gitlab/status"))
return { connected: false, baseUrl: "https://gl" };
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST")
return jsonResponse({ error: "bad pat" }, 401);
return [];
});
render(tree(makeStore()));
await flush();
fireEvent.input(screen.getByLabelText("GitLab personal access token"), {
target: { value: "glpat-bad" },
});
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await flush();
expect(screen.getByText("bad pat")).toBeInTheDocument();
expect(screen.getByText("Connect GitLab")).toBeInTheDocument();
});
it("connected on load fetches repos immediately", async () => {
mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/quick")];
return [];
});
render(tree(makeStore()));
await flush();
expect(screen.getByText("g/quick")).toBeInTheDocument();
});
});
describe("SpawnView repo picker", () => {
function connectedMock(): void {
mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos"))
return [repo("g/alpha"), repo("g/beta", "dev")];
return [];
});
}
it("filters repos by query, keyboard selects, empty filter message", async () => {
connectedMock();
render(tree(makeStore()));
await flush();
expect(screen.getByText("g/alpha")).toBeInTheDocument();
const filter = screen.getByLabelText("Filter repositories");
fireEvent.input(filter, { target: { value: "beta" } });
expect(screen.queryByText("g/alpha")).toBeNull();
expect(screen.getByText("g/beta")).toBeInTheDocument();
const item = screen
.getByText("g/beta")
.closest(".repo-item") as HTMLElement;
fireEvent.keyDown(item, { key: "Enter" });
expect(screen.getByLabelText("Branch")).toHaveValue("dev");
fireEvent.input(filter, { target: { value: "zzz" } });
expect(screen.getByText("no matching repos")).toBeInTheDocument();
});
it("shows loading repos while the list is pending", async () => {
const gate = { resolve: null as ((v: unknown) => void) | null };
mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos"))
return new Promise((res) => {
gate.resolve = res;
});
return [];
});
render(tree(makeStore()));
await flush();
expect(screen.getByText("loading repos…")).toBeInTheDocument();
gate.resolve?.([repo("g/late")]);
await flush();
expect(await screen.findByText("g/late")).toBeInTheDocument();
});
it("spawn without selection shows pick-a-repo error", async () => {
connectedMock();
render(tree(makeStore()));
await flush();
expect(screen.getByLabelText("Spawn container")).toBeDisabled();
expect(screen.getByText("select a repo above")).toBeInTheDocument();
});
});
describe("SpawnView spawn+poll", () => {
beforeEach(() => {
vi.useFakeTimers();
});
it("spawns, polls until online, then navigates to the chat", async () => {
const posts: Array<[string, RequestInit | undefined]> = [];
let sessionsOnline = false; // flipped after the first poll tick
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
posts.push([url, init]);
return { sessionId: "new-1", containerId: "abc123def456" };
}
if (url.endsWith("/api/sessions"))
return sessionsOnline
? [
{
id: "new-1",
name: "spawned",
cwd: "/w",
model: "m",
provider: "p",
agent: true,
repo: "g/proj",
startedAt: 1,
online: true,
lastEventAt: 1,
},
]
: [];
if (url.endsWith("/api/spawn/status")) return [];
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/proj")];
return [];
});
const store = makeStore();
const { unmount } = render(tree(store));
await flush();
fireEvent.click(screen.getByText("g/proj"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(posts).toHaveLength(1);
expect((posts[0]?.[1] as RequestInit).body).toBe(
JSON.stringify({ repo: "g/proj", branch: "main" }),
describe("Bubble", () => {
it("renders plain text per role class", () => {
const { container } = render(
<Bubble
msg={msg({ role: "user", text: "hi there" })}
tools={new Map()}
/>,
);
expect(screen.getByText("Spawning…")).toBeInTheDocument();
expect(screen.getByText(/container abc123def456/)).toBeInTheDocument();
expect(
screen.getByText("waiting for session to come online…"),
).toBeInTheDocument();
// first tick: session not online yet
await act(async () => {
vi.advanceTimersByTime(1500);
});
await flush();
expect(screen.queryByTestId("chat-route")).toBeNull();
// session comes online -> next tick navigates
sessionsOnline = true;
await act(async () => {
vi.advanceTimersByTime(1500);
});
await flush();
expect(screen.getByTestId("chat-route")).toBeInTheDocument();
unmount();
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
expect(container.textContent).toContain("hi there");
});
it("spawning view shows job line from status and spawn error text", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sx", containerId: "" };
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
return [];
});
const store = makeStore({
spawnJobs: [
{ sessionId: "sx", repo: "g/p", state: "cloning", containerId: "" },
it("renders empty assistant text as nothing but shows nothing when empty", () => {
const { container } = render(
<Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />,
);
expect(container.querySelector(".bubble")?.children).toHaveLength(0);
});
it("toolResult collapses to a one-line preview, expands to full text", async () => {
const long = `${"x".repeat(200)}`;
render(
<Bubble
msg={msg({ role: "toolResult", text: long })}
tools={new Map()}
/>,
);
const details = screen
.getByText("result")
.closest("details") as HTMLDetailsElement;
expect(details.open).toBe(false);
expect(details.textContent).toContain("…");
await userEvent.click(screen.getByText("result"));
expect(details.open).toBe(true);
expect(details.textContent).toContain(long);
});
it("toolResult with short text keeps full one-line preview", () => {
render(
<Bubble
msg={msg({ role: "toolResult", text: "short out" })}
tools={new Map()}
/>,
);
const details = screen
.getByText("result")
.closest("details") as HTMLDetailsElement;
expect(details.textContent).toContain("short out");
expect(details.textContent).not.toContain("…");
});
it("flattens whitespace in previews", () => {
render(
<Bubble
msg={msg({ role: "toolResult", text: "a\n\n b c" })}
tools={new Map()}
/>,
);
expect(screen.getAllByText("a b c").length).toBeGreaterThan(0);
});
it("assistant tool calls attach tool cards", async () => {
const tools = new Map<string, ToolState>([
[
"c1",
tool({
id: "c1",
name: "bash",
args: "ls -la",
running: false,
isError: false,
preview: "file",
}),
],
});
render(tree(store));
await flush();
fireEvent.click(screen.getByText("g/p"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(screen.getByText("Spawning…")).toBeInTheDocument();
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
]);
render(
<Bubble
msg={msg({
role: "assistant",
text: "finished",
toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }],
})}
tools={tools}
/>,
);
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
expect(screen.getByText("finished")).toBeInTheDocument();
const summary = screen
.getByText("🛠 bash")
.closest("summary") as HTMLElement;
const card = summary.closest("details") as HTMLDetailsElement;
expect(card.open).toBe(false);
await userEvent.click(summary);
expect(card.open).toBe(true);
expect(card.textContent).toContain("ls -la");
expect(card.textContent).toContain("file");
});
it("branch left blank sends repo only; poll refresh failure toasts", async () => {
const bodies: string[] = [];
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
bodies.push(String(init.body));
return { sessionId: "new-2", containerId: "cccccccccccc" };
}
if (url.endsWith("/api/spawn/status")) return [];
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/blank")];
return [];
});
const failingRefresh = makeStore({
refresh: async (): Promise<void> => {
throw new Error("boom");
},
});
render(tree(failingRefresh));
await flush();
fireEvent.click(screen.getByText("g/blank"));
fireEvent.change(screen.getByLabelText("Branch"), {
target: { value: "" },
});
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(bodies).toEqual([JSON.stringify({ repo: "g/blank" })]);
await act(async () => {
vi.advanceTimersByTime(1500);
});
await flush();
expect(PUSH_TOAST).toHaveBeenCalledWith("boom");
it("tool card status variants: running, error, done", () => {
const tools = new Map<string, ToolState>([
["c1", tool({ id: "c1", running: true })],
["c2", tool({ id: "c2", running: false, isError: true })],
["c3", tool({ id: "c3", running: false, isError: false })],
]);
render(
<Bubble
msg={msg({
role: "assistant",
toolCalls: ["c1", "c2", "c3"].map((id) => ({
id,
name: `t-${id}`,
argsJson: "{}",
})),
})}
tools={tools}
/>,
);
expect(screen.getByText("working…")).toBeInTheDocument();
expect(screen.getByText("error")).toBeInTheDocument();
expect(screen.getByText("done")).toBeInTheDocument();
});
it("spawn POST failure shows the error", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return jsonResponse({ error: "no docker" }, 500);
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/x")];
return [];
});
render(tree(makeStore()));
await flush();
fireEvent.click(screen.getByText("g/x"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(screen.getByText("no docker")).toBeInTheDocument();
it("tool call with no matching state renders no card", () => {
const { container } = render(
<Bubble
msg={msg({
role: "assistant",
toolCalls: [{ id: "ghost", name: "x", argsJson: "{}" }],
})}
tools={new Map()}
/>,
);
expect(container.querySelectorAll(".tool-card")).toHaveLength(0);
});
it("polling gives up after the tick cap and stays on the page", async () => {
let statusCalls = 0;
mockFetchJson((url) => {
if (url.endsWith("/api/spawn/status")) {
statusCalls += 1;
return [];
}
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/slow")];
if (url.endsWith("/api/spawn"))
return { sessionId: "slow-1", containerId: "d" };
return [];
});
const { unmount } = render(tree(makeStore()));
await flush();
fireEvent.click(screen.getByText("g/slow"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(screen.getByText("Spawning…")).toBeInTheDocument();
it("thinking block only for non-empty thinking", async () => {
render(<Bubble msg={msg({ thinking: "because" })} tools={new Map()} />);
const details = screen
.getByText("thinking")
.closest("details") as HTMLDetailsElement;
await userEvent.click(screen.getByText("thinking"));
expect(details.open).toBe(true);
expect(details.textContent).toContain("because");
await act(async () => {
vi.advanceTimersByTime(1500 * 402);
});
await flush();
const afterCap = statusCalls;
await act(async () => {
vi.advanceTimersByTime(1500 * 10);
});
await flush();
expect(statusCalls).toBe(afterCap);
expect(screen.getByText("Spawning…")).toBeInTheDocument();
unmount();
const { container } = render(
<Bubble msg={msg({ thinking: null })} tools={new Map()} />,
);
expect(container.querySelector(".thinking")).toBeNull();
});
it("spawn job line renders from store.spawnJobs", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sj-1", containerId: "cid" };
if (url.endsWith("/api/spawn/status")) return [];
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
return [];
});
const store = makeStore();
const { rerender } = render(tree(store));
await flush();
fireEvent.click(screen.getByText("g/p"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(screen.getByText("Spawning…")).toBeInTheDocument();
store.spawnJobs = [{ repo: "g/p", state: "cloning", sessionId: "sj-1" }];
act(() => {
rerender(tree(store));
});
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
it("streaming bubble shows the caret", () => {
const { container } = render(
<Bubble msg={msg({ text: "par", streaming: true })} tools={new Map()} />,
);
expect(container.querySelector(".stream-caret")).not.toBeNull();
});
});
describe("SpawnSteps", () => {
it("renders progress steps matching job state", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sp1", containerId: "" };
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
return [];
describe("TypingIndicator", () => {
it("renders three dots with aria-live", () => {
const { container } = render(<TypingIndicator />);
expect(container.querySelector('[aria-live="polite"]')).not.toBeNull();
expect(container.querySelectorAll(".dot")).toHaveLength(3);
});
});
describe("ChatStream", () => {
function stream(p: {
messages: ChatMessage[];
busy: boolean;
hasOlder?: boolean;
loadingOlder?: boolean;
onOlder?: () => void;
}): React.ReactElement {
return (
<ChatStream
messages={p.messages}
tools={new Map()}
busy={p.busy}
hasOlder={p.hasOlder ?? false}
loadingOlder={p.loadingOlder ?? false}
onLoadOlder={p.onOlder ?? (() => undefined)}
/>
);
}
it("renders messages and typing indicator while busy with no open stream", () => {
const { container, rerender } = render(
stream({ messages: [msg({ key: "a", text: "one" })], busy: true }),
);
expect(container.querySelector(".typing")).not.toBeNull();
rerender(
stream({
messages: [
msg({ key: "a", text: "one" }),
msg({ key: "b", streaming: true }),
],
busy: true,
}),
);
expect(container.querySelector(".typing")).toBeNull();
rerender(
stream({ messages: [msg({ key: "a", text: "one" })], busy: false }),
);
expect(container.querySelector(".typing")).toBeNull();
});
it("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => {
const { container, rerender } = render(
stream({ messages: [msg({ key: "a" })], busy: false }),
);
const scroller = container.querySelector(".chat-scroll") as HTMLElement;
Object.defineProperty(scroller, "scrollHeight", {
configurable: true,
value: 1000,
});
const store = makeStore({
spawnJobs: [{ sessionId: "sp1", repo: "g/p", state: "building", containerId: "" }],
Object.defineProperty(scroller, "clientHeight", {
configurable: true,
value: 300,
});
render(tree(store));
await flush();
fireEvent.click(screen.getByText("g/p"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
const steps = document.querySelectorAll(".spawn-progress .step");
expect(steps).toHaveLength(4);
expect(steps[0]?.className).toContain("done");
expect(steps[1]?.className).toContain("current");
expect(steps[2]?.className).toBe("step");
scroller.dispatchEvent(new Event("scroll"));
// pinned: scrollTop at bottom
scroller.scrollTop = 700;
Object.defineProperty(scroller, "scrollTop", {
configurable: true,
writable: true,
value: 700,
});
scroller.dispatchEvent(new Event("scroll"));
rerender(
stream({
messages: [msg({ key: "a" }), msg({ key: "b" })],
busy: false,
}),
);
expect(scroller.scrollTop).toBe(1000);
// scroll far up -> unpin
Object.defineProperty(scroller, "scrollTop", {
configurable: true,
writable: true,
value: 0,
});
scroller.dispatchEvent(new Event("scroll"));
rerender(
stream({
messages: [msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" })],
busy: false,
}),
);
expect(scroller.scrollTop).toBe(0);
// scroll near bottom (within 80px) -> pinned again
Object.defineProperty(scroller, "scrollTop", {
configurable: true,
writable: true,
value: 940,
});
scroller.dispatchEvent(new Event("scroll"));
rerender(
stream({
messages: [
msg({ key: "a" }),
msg({ key: "b" }),
msg({ key: "c" }),
msg({ key: "d" }),
],
busy: false,
}),
);
expect(scroller.scrollTop).toBe(1000);
});
it("load-older button renders only when an older page exists and fires on click", () => {
const onOlder = vi.fn();
const { rerender } = render(
stream({ messages: [msg({ key: "a" })], busy: false }),
);
expect(
screen.queryByRole("button", { name: "Load older messages" }),
).toBeNull();
rerender(
stream({
messages: [msg({ key: "a" })],
busy: false,
hasOlder: true,
onOlder,
}),
);
const btn = screen.getByRole("button", { name: "Load older messages" });
fireEvent.click(btn);
expect(onOlder).toHaveBeenCalledTimes(1);
rerender(
stream({
messages: [msg({ key: "a" })],
busy: false,
hasOlder: true,
loadingOlder: true,
onOlder,
}),
);
expect(
screen.getByRole("button", { name: "Load older messages" }),
).toBeDisabled();
});
});
describe("copy button", () => {
it("copy button writes message text and flashes copied", async () => {
const writeText = vi.fn(() => Promise.resolve());
Object.assign(navigator, { clipboard: { writeText } });
render(
<Bubble
msg={{
key: "k",
role: "assistant",
text: "copy me",
thinking: null,
toolCalls: [],
toolCallId: null,
streaming: false,
}}
tools={new Map()}
/>,
);
const btn = screen.getByRole("button", { name: "Copy message" });
fireEvent.click(btn);
expect(writeText).toHaveBeenCalledWith("copy me");
await waitFor(() => expect(screen.getByText("copied")).toBeInTheDocument());
});
it("user bubbles get a copy button, toolResults do not", () => {
const { rerender } = render(
<Bubble
msg={{
key: "u",
role: "user",
text: "hi",
thinking: null,
toolCalls: [],
toolCallId: null,
streaming: false,
}}
tools={new Map()}
/>,
);
expect(
screen.getByRole("button", { name: "Copy message" }),
).toBeInTheDocument();
rerender(
<Bubble
msg={{
key: "t",
role: "toolResult",
text: "r",
thinking: null,
toolCalls: [],
toolCallId: "c1",
streaming: false,
}}
tools={new Map()}
/>,
);
expect(screen.queryByRole("button", { name: "Copy message" })).toBeNull();
});
});
+11 -11
View File
@@ -1,12 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import type {
GitlabStatus,
Repo,
SessionListItem,
SpawnJob,
SpawnResponse,
} from "./protocol";
import type { GitlabStatus, Repo, SpawnJob, SpawnResponse } from "./protocol";
import { Route } from "./protocol";
import { errMessage, fetchJson } from "./api";
import type { SessionsStore } from "./store";
@@ -19,14 +13,21 @@ interface Props {
pushToast: (text: string) => void;
}
const SPAWN_STEPS: string[] = ["cloning", "building", "creating", "running"];
function SpawnSteps({ state }: { state: string }): React.ReactNode {
const idx = SPAWN_STEPS.indexOf(state);
if (state === "error") return null;
const currentStepIndex: number = idx >= 0 ? idx : 0;
return (
<div className="spawn-progress" aria-label={`spawn progress: ${state}`}>
<div
className="spawn-progress"
role="progressbar"
aria-label={`spawn progress: ${state}`}
aria-valuemin={1}
aria-valuemax={SPAWN_STEPS.length}
aria-valuenow={currentStepIndex + 1}
>
{SPAWN_STEPS.map((step, i) => (
<span
key={step}
@@ -151,9 +152,8 @@ export default function SpawnView({ store, pushToast }: Props) {
}
void (async () => {
try {
const list = await fetchJson<SessionListItem[]>(Route.Sessions);
const list = await store.refresh();
const s = list.find((x) => x.id === sessionId);
await store.refresh();
if (s !== undefined && s.online) {
if (timerRef.current !== null)
window.clearInterval(timerRef.current);
+13
View File
@@ -475,6 +475,15 @@ a:hover {
max-width: 780px;
margin: 0 auto;
}
.load-older {
display: flex;
justify-content: center;
padding: 0 0 14px;
}
.load-older .btn-secondary {
padding: 5px 16px;
font-size: 12.5px;
}
.bubble-row {
display: flex;
@@ -523,6 +532,10 @@ a:hover {
.bubble-row:hover .msg-copy {
opacity: 1;
}
.bubble-row .msg-copy:focus-visible,
.bubble-row:focus-within .msg-copy {
opacity: 1;
}
.bubble-row.user .msg-copy {
right: auto;
left: -4px;
+189 -4
View File
@@ -4,11 +4,12 @@ import { getSettings, saveSettings } from "./settings";
import { classNames, relativeTime, useSessions, useToasts } from "./store";
import {
FakeWebSocket,
jsonResponse,
mockFetchJson,
seedSettings,
stubReload,
} from "./test/setup";
import type { EventFrame } from "./protocol";
import type { EventFrame, SessionListItem } from "./protocol";
const NOW = new Date("2024-05-01T12:00:00Z").getTime();
@@ -74,7 +75,13 @@ describe("useSessions", () => {
it("seeds via REST, updates from session_list and spawn_status frames, refresh works", async () => {
seedSettings();
const sessions = [{ id: "s1", name: "one", online: true }];
const sessions: SessionListItem[] = [
{
id: "s1",
name: "one",
online: true,
},
] as SessionListItem[];
const fetchMock = mockFetchJson((url) => {
if (url.includes("/api/sessions"))
return url.includes("/events") ? [] : sessions;
@@ -108,13 +115,17 @@ describe("useSessions", () => {
{ repo: "g/p", state: "cloning" },
]);
// refresh resolves to the fetched rows (S7) and re-seeds the list
let out: SessionListItem[] | undefined;
await act(async () => {
await result.current?.refresh();
out = await result.current?.refresh();
});
expect(out).toEqual(sessions);
expect(result.current?.sessions).toEqual(sessions);
expect(fetchMock).toHaveBeenCalled();
});
it("refresh failure pushes a toast", async () => {
it("refresh failure pushes a toast and resolves to an empty list", async () => {
seedSettings();
mockFetchJson(() => {
throw new Error("network down");
@@ -125,6 +136,11 @@ describe("useSessions", () => {
expect(push).toHaveBeenCalledWith("sessions: network down"),
);
expect(result.current).not.toBeNull();
let out: SessionListItem[] | undefined;
await act(async () => {
out = await result.current?.refresh();
});
expect(out).toEqual([]);
});
it("subscribe routes events frames for the session and detaches on unsubscribe", async () => {
@@ -192,6 +208,130 @@ describe("useSessions", () => {
loc.restore();
});
it("3 failed connects + 401 REST probe clears settings and reloads (S3)", async () => {
vi.useFakeTimers();
vi.spyOn(Math, "random").mockReturnValue(1);
seedSettings();
mockFetchJson((url) =>
url.includes("/api/sessions")
? jsonResponse({ error: "unauthorized" }, 401)
: [],
);
const loc = stubReload();
const push = vi.fn();
renderHook(() => useSessions(push));
const failConnect = (): void => {
act(() => FakeWebSocket.last().serverClose(1006));
};
failConnect();
act(() => {
vi.advanceTimersByTime(500);
});
failConnect();
act(() => {
vi.advanceTimersByTime(1000);
});
failConnect(); // 3rd consecutive connect failure -> probe -> 401
for (let i = 0; i < 8; i += 1) {
// eslint-disable-next-line no-await-in-loop
await act(async () => {
await Promise.resolve();
});
}
expect(getSettings()).toBeNull();
expect(loc.reload).toHaveBeenCalled();
loc.restore();
vi.useRealTimers();
});
it("probe failing with a non-401 error never clears settings (S3)", async () => {
vi.useFakeTimers();
vi.spyOn(Math, "random").mockReturnValue(1);
seedSettings();
mockFetchJson((url) => {
if (url.includes("/api/sessions")) throw new Error("daemon unreachable");
return [];
});
const loc = stubReload();
const push = vi.fn();
renderHook(() => useSessions(push));
const failConnect = (): void => {
act(() => FakeWebSocket.last().serverClose(1006));
};
failConnect();
act(() => {
vi.advanceTimersByTime(500);
});
failConnect();
act(() => {
vi.advanceTimersByTime(1000);
});
failConnect();
for (let i = 0; i < 8; i += 1) {
// eslint-disable-next-line no-await-in-loop
await act(async () => {
await Promise.resolve();
});
}
expect(getSettings()).not.toBeNull();
expect(loc.reload).not.toHaveBeenCalled();
loc.restore();
vi.useRealTimers();
});
it("successful probe resets the failure counter, no clear (S3)", async () => {
vi.useFakeTimers();
vi.spyOn(Math, "random").mockReturnValue(1);
seedSettings();
let sessionsCalls = 0;
mockFetchJson((url) => {
if (url.includes("/api/sessions")) {
sessionsCalls += 1;
return [];
}
return [];
});
const loc = stubReload();
const push = vi.fn();
renderHook(() => useSessions(push));
const failConnect = (): void => {
act(() => FakeWebSocket.last().serverClose(1006));
};
const flush = async (): Promise<void> => {
for (let i = 0; i < 8; i += 1) {
// eslint-disable-next-line no-await-in-loop
await act(async () => {
await Promise.resolve();
});
}
};
failConnect();
act(() => {
vi.advanceTimersByTime(500);
});
failConnect();
act(() => {
vi.advanceTimersByTime(1000);
});
failConnect();
await flush();
// mount seed (1) + exactly one probe (2); nothing else
expect(sessionsCalls).toBe(2);
expect(loc.reload).not.toHaveBeenCalled();
expect(getSettings()).not.toBeNull();
// counter reset: two more failures stay under the threshold, no probe
failConnect();
act(() => {
vi.advanceTimersByTime(2000);
});
failConnect();
await flush();
expect(sessionsCalls).toBe(2);
loc.restore();
vi.useRealTimers();
});
it("unmount closes the manager", async () => {
seedSettings();
mockFetchJson(() => []);
@@ -203,6 +343,51 @@ describe("useSessions", () => {
expect(sock.closeCode).toBe(4900);
expect(sock.onclose).toBeNull();
});
it("returned store object keeps identity when only unrelated state changes (S2)", async () => {
seedSettings();
// a stable list reference: only identity behavior is under test here
const stable: SessionListItem[] = [];
mockFetchJson(() => stable);
const push = vi.fn();
const { result, rerender } = renderHook(() => useSessions(push));
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
// let the mount-time seed settle
for (let i = 0; i < 4; i += 1) {
// eslint-disable-next-line no-await-in-loop
await act(async () => {
await Promise.resolve();
});
}
const first = result.current;
rerender();
rerender();
expect(result.current).toBe(first); // memoized, not a fresh object
});
it("store identity changes when the sessions list changes", async () => {
seedSettings();
let list: SessionListItem[] = [];
mockFetchJson(() => list);
const push = vi.fn();
const { result } = renderHook(() => useSessions(push));
await waitFor(() => expect(result.current).not.toBeNull());
const first = result.current;
const sock = FakeWebSocket.last();
act(() => sock.serverOpen());
list = [
{
id: "s9",
name: "x",
online: true,
} as SessionListItem,
];
await act(async () => {
await result.current?.refresh();
});
expect(result.current).not.toBe(first);
expect(result.current?.sessions.map((s) => s.id)).toEqual(["s9"]);
});
});
describe("settings persistence used by the store", () => {
+45 -9
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type {
EventFrame,
ServerFrame,
@@ -8,13 +8,15 @@ import type {
import { Route } from "./protocol";
import { buildWsUrl, getSettings, clearSettings } from "./settings";
import { createWsManager, type WsManager, type WsState } from "./ws";
import { errMessage, fetchJson } from "./api";
import { ApiError, errMessage, fetchJson } from "./api";
// ---------- small shared helpers ----------
const MINUTE_MS: number = 60_000;
const HOUR_MS: number = 60 * MINUTE_MS;
const DAY_MS: number = 24 * HOUR_MS;
/** consecutive ws connect failures before probing REST auth (S3) */
const AUTH_PROBE_FAILURES: number = 3;
export function relativeTime(ts: number | null): string {
if (ts === null) return "never";
@@ -60,7 +62,8 @@ export interface SessionsStore {
sessions: SessionListItem[];
state: WsState;
spawnJobs: SpawnJob[];
refresh: () => Promise<void>;
/** Fetches the list; resolves to the fetched rows (empty on failure). */
refresh: () => Promise<SessionListItem[]>;
/** Subscribe to one session's event stream (protocol allows one at a time). */
subscribe: (
sessionId: string,
@@ -88,21 +91,46 @@ export function useSessions(
if (frame.type === "session_list") setSessions(frame.sessions);
else if (frame.type === "spawn_status") setSpawnJobs(frame.jobs);
});
m.onAuthError(() => {
const handleAuthError = (): void => {
clearSettings();
window.location.reload();
};
m.onAuthError(handleAuthError);
// daemon rejects /ws pre-upgrade with 401: the browser only ever sees
// close 1006, so after repeated connect failures probe REST auth (S3)
let connectFailures = 0;
const offFail = m.onState((s) => {
if (s === "open") {
connectFailures = 0;
return;
}
if (s !== "closed") return;
connectFailures += 1;
if (connectFailures < AUTH_PROBE_FAILURES) return;
void fetchJson(Route.Sessions)
.then(() => {
connectFailures = 0;
})
.catch((err: unknown) => {
if (err instanceof ApiError && err.status === 401) handleAuthError();
});
});
const refresh = async (): Promise<void> => {
const refresh = async (): Promise<SessionListItem[]> => {
try {
setSessions(await fetchJson<SessionListItem[]>(Route.Sessions));
const list = await fetchJson<SessionListItem[]>(Route.Sessions);
setSessions(list);
return list;
} catch (err) {
pushToast(`sessions: ${errMessage(err)}`);
return [];
}
};
void refresh();
return () => {
offFail();
offState();
offFrames();
m.close();
@@ -110,11 +138,14 @@ export function useSessions(
};
}, [pushToast]);
const refresh = useCallback(async (): Promise<void> => {
const refresh = useCallback(async (): Promise<SessionListItem[]> => {
try {
setSessions(await fetchJson<SessionListItem[]>(Route.Sessions));
const list = await fetchJson<SessionListItem[]>(Route.Sessions);
setSessions(list);
return list;
} catch (err) {
pushToast(`sessions: ${errMessage(err)}`);
return [];
}
}, [pushToast]);
@@ -137,9 +168,14 @@ export function useSessions(
[manager],
);
const store = useMemo(
() => ({ sessions, state, spawnJobs, refresh, subscribe }),
[sessions, state, spawnJobs, refresh, subscribe],
);
// hooks above must all run before any early return: clearing settings
// mid-flight (ws auth failure) must not change the hook order on re-render
if (getSettings() === null) return null;
return { sessions, state, spawnJobs, refresh, subscribe };
return store;
}