fix(review round 2): daemon symlink tars, orphan-safe removes, unique seed names, git ctx, IsErrNotFound, collision-free slugs, branch validation; web first-run WS, draft reset, IME guard, churn fix, test swap repair; 106 daemon + 190 web tests green

This commit is contained in:
Raphael Westphal
2026-08-18 19:13:51 +02:00
parent 95d2b5da4b
commit 66c90e48bb
22 changed files with 1476 additions and 964 deletions
+22 -4
View File
@@ -102,6 +102,24 @@ describe("App gate", () => {
);
await screen.findByLabelText("Sessions");
});
it("first-run: saving settings starts the ws manager (A)", async () => {
seedApi();
renderApp();
fireEvent.input(screen.getByLabelText("Bearer token"), {
target: { value: "tok" },
});
fireEvent.submit(
screen
.getByRole("button", { name: "Connect" })
.closest("form") as HTMLFormElement,
);
// before the fix the effect deps never changed after the gate save,
// so no manager ever started and the app stayed on the connecting banner
await screen.findByLabelText("Sessions");
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
await screen.findByText(/reconnecting/i);
});
});
describe("App shell", () => {
@@ -113,9 +131,7 @@ describe("App shell", () => {
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
const sock = FakeWebSocket.last();
act(() => sock.serverOpen());
expect(
screen.getByTitle("ws open"),
).toBeInTheDocument();
expect(screen.getByTitle("ws open")).toBeInTheDocument();
const sidebar = screen.getByLabelText("Sessions");
const links = Array.from(sidebar.querySelectorAll("a.session-link")).map(
@@ -229,7 +245,9 @@ describe("ErrorBoundary direct", () => {
function Bomb(): React.ReactNode {
throw new Error("kaboom-ui");
}
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
function Harness(): React.ReactNode {
return (
<MemoryRouter>
+1 -1
View File
@@ -88,7 +88,7 @@ export default function App() {
const [configured, setConfigured] = useState<boolean>(getSettings() !== null);
const [sidebarOpen, setSidebarOpen] = useState<boolean>(false);
const { toasts, push } = useToasts();
const store = useSessions(push);
const store = useSessions(push, configured);
const location = useLocation();
useEffect(() => {
+367 -440
View File
@@ -1,483 +1,410 @@
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";
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<SessionListItem[]> =>
fetchJson<SessionListItem[]>(ApiRoute.Sessions),
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("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");
});
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);
});
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();
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("…");
});
});
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("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(
<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();
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();
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("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("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("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();
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);
});
});
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("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("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();
const { container } = render(
<Bubble msg={msg({ thinking: null })} tools={new Map()} />,
);
expect(container.querySelector(".thinking")).toBeNull();
});
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("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 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("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();
describe("SpawnView spawn+poll", () => {
beforeEach(() => {
vi.useFakeTimers();
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("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 [];
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();
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" }),
Object.defineProperty(scroller, "clientHeight", {
configurable: true,
value: 300,
});
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.getByText("Spawning…")).toBeInTheDocument();
expect(screen.getByText(/container abc123def456/)).toBeInTheDocument();
expect(
screen.getByText("waiting for session to come online…"),
).toBeInTheDocument();
screen.queryByRole("button", { name: "Load older messages" }),
).toBeNull();
// first tick: session not online yet
await act(async () => {
vi.advanceTimersByTime(1500);
});
await flush();
expect(screen.queryByTestId("chat-route")).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);
// 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();
rerender(
stream({
messages: [msg({ key: "a" })],
busy: false,
hasOlder: true,
loadingOlder: true,
onOlder,
}),
);
expect(
screen.getByRole("button", { name: "Load older messages" }),
).toBeDisabled();
});
});
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(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();
});
describe("copy button", () => {
it("copy button writes message text and flashes copied", async () => {
vi.useFakeTimers();
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 vi.waitFor(() =>
expect(screen.getByText("copied")).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("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("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();
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("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" }];
// feedback clears after COPY_FEEDBACK_MS (H)
act(() => {
rerender(tree(store));
vi.advanceTimersByTime(1200);
});
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
});
});
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 [];
});
const store = makeStore({
spawnJobs: [
{ sessionId: "sp1", repo: "g/p", state: "building", containerId: "" },
],
});
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");
expect(screen.getByText("copy")).toBeInTheDocument();
vi.useRealTimers();
});
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 [];
});
const store = makeStore({
spawnJobs: [
{ sessionId: "sp2", repo: "g/p", state: "building", containerId: "" },
],
});
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
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();
});
});
+5 -2
View File
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from "react";
import type { ChatMessage, ToolState } from "./derive";
const PREVIEW_LEN: number = 120;
const COPY_FEEDBACK_MS: number = 1200;
const PIN_THRESHOLD_PX: number = 80;
function oneLine(text: string): string {
const flat = text.replace(/\s+/g, " ").trim();
@@ -18,7 +20,7 @@ function CopyButton({ text }: { text: string }): React.ReactNode {
onClick={() => {
void navigator.clipboard?.writeText(text).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
window.setTimeout(() => setCopied(false), COPY_FEEDBACK_MS);
});
}}
>
@@ -156,7 +158,8 @@ export default function ChatStream({
const onScroll = (): void => {
const el = scrollRef.current;
if (el === null) return;
pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
pinnedRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX;
};
useEffect(() => {
+105
View File
@@ -313,6 +313,25 @@ describe("ChatView", () => {
expect(ta.value).toBe("");
});
it("Enter during IME composition confirms candidates, not a send (E)", async () => {
const fetchMock = mockFetchJson((_url, init) =>
init?.method === "POST" ? { ok: true } : [],
);
renderChat(makeStore());
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
await userEvent.type(ta, "こん");
fireEvent.keyDown(ta, { key: "Enter", isComposing: true });
const posts = (): number =>
fetchMock.mock.calls.filter(
(c) => (c[1] as RequestInit | undefined)?.method === "POST",
).length;
expect(posts()).toBe(0);
expect(ta.value).toBe("こん"); // composition text survives the confirm
fireEvent.keyDown(ta, { key: "Enter" }); // composition finished: real send
await vi.waitFor(() => expect(posts()).toBe(1));
});
it("abort posts to the abort route", async () => {
const fetchMock = mockFetchJson((_url, init) =>
init?.method === "POST" ? { ok: true } : [],
@@ -810,6 +829,92 @@ describe("ChatView stale async guards (S1/S4) and send draft (S6)", () => {
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("nope"));
expect(ta.value).toBe("precious draft");
});
it("session switch clears the draft instead of leaking it (B)", async () => {
mockFetchJson(() => []);
render(
<MemoryRouter initialEntries={["/s/s1"]}>
<Switcher state="open" />
</MemoryRouter>,
);
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
await userEvent.type(ta, "secret for s1");
await userEvent.click(
screen.getByRole("button", { name: "switch session" }),
);
await screen.findByText("ghost");
expect(ta.value).toBe(""); // s1's draft must not appear in ghost's composer
});
it("session switch resets a stuck sending flag (B)", async () => {
let releaseSend: ((v: unknown) => void) | null = null;
mockFetchJson((_url, init) => {
if (init?.method === "POST")
return new Promise<unknown>((res) => {
releaseSend = res;
});
return [];
});
render(
<MemoryRouter initialEntries={["/s/s1"]}>
<Switcher state="open" />
</MemoryRouter>,
);
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
await userEvent.type(ta, "hangs");
fireEvent.keyDown(ta, { key: "Enter" }); // send starts and never settles
await userEvent.click(
screen.getByRole("button", { name: "switch session" }),
);
await screen.findByText("ghost");
const ta2 = screen.getByLabelText("Message") as HTMLTextAreaElement;
await userEvent.type(ta2, "fresh");
// sending=true surviving the switch would keep the ghost button disabled
expect(screen.getByLabelText("Send message")).toBeEnabled();
await act(async () => {
releaseSend?.([]);
});
});
it("resubscribes only when the subscribe identity (manager) changes, not on store churn (G)", async () => {
mockFetchJson(() => []);
let subscribeCalls: number = 0;
const makeSubscribe =
(): SessionsStore["subscribe"] => (sessionId, onEvents) => {
subscribeCalls += 1;
currentSub = { sessionId, onEvents };
return () => {
if (currentSub !== null && currentSub.sessionId === sessionId)
currentSub = null;
};
};
const liveSubscribe = makeSubscribe();
const { rerender } = renderChat({
...makeStore(),
subscribe: liveSubscribe,
});
await waitFor(() => expect(subscribeCalls).toBe(1));
// store identity changes with every session_list frame; the live
// subscription must survive instead of unsubscribe/resubscribe churn
const churned = makeStore({
sessions: [{ ...sessions[0]!, online: false }],
});
rerenderChatAgain(rerender, { ...churned, subscribe: liveSubscribe });
for (let i = 0; i < 4; i += 1) {
// eslint-disable-next-line no-await-in-loop
await act(async () => {
await Promise.resolve();
});
}
expect(subscribeCalls).toBe(1);
expect(currentSub).not.toBeNull();
// manager replaced -> new subscribe identity -> resubscribe happens
rerenderChatAgain(rerender, { ...makeStore(), subscribe: makeSubscribe() });
await waitFor(() => expect(subscribeCalls).toBe(2));
});
});
describe("ChatView unknown session", () => {
+10 -2
View File
@@ -76,6 +76,10 @@ export default function ChatView({ store, pushToast }: Props) {
setEvents([]);
setHasOlder(false);
lastSeqRef.current = 0;
// a draft (or stuck sending state) from the previous session must not
// leak into the new one
setDraft("");
setSending(false);
let alive = true;
void (async () => {
try {
@@ -96,11 +100,13 @@ export default function ChatView({ store, pushToast }: Props) {
};
}, [sessionId, applyEvents]);
// ws subscription
// ws subscription: dep on store.subscribe (stable per manager) rather than
// the whole store object — every sessions-list change would otherwise
// churn unsubscribe/resubscribe and can drop events in the gap
useEffect(() => {
if (sessionId.length === 0 || store.state !== "open") return;
return store.subscribe(sessionId, applyEvents);
}, [sessionId, store.state, store, applyEvents]);
}, [sessionId, store.state, store.subscribe, applyEvents]);
// missed events after reconnect (persisted only); a response for a
// previous session must not merge here nor touch the cursor (S1)
@@ -187,6 +193,8 @@ export default function ChatView({ store, pushToast }: Props) {
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
// IME composition: Enter confirms the candidate window, not a send
if (e.nativeEvent.isComposing) return;
if (e.key === SEND_KEY && !e.shiftKey) {
e.preventDefault();
void send();
+6
View File
@@ -77,6 +77,12 @@ describe("SessionsView", () => {
expect(skel).not.toBeNull();
});
it("sessions arriving before the 600ms window end the skeleton immediately (F)", () => {
renderView({ sessions: [session({ id: "s1", name: "live" })] });
expect(document.querySelector(".skeleton")).toBeNull();
expect(screen.getByText("live")).toBeInTheDocument();
});
it("renders cards sorted by last activity with fallbacks", () => {
renderView({
sessions: [
+6
View File
@@ -79,6 +79,12 @@ export default function SessionsView({
return () => window.clearTimeout(t);
}, []);
useEffect(() => {
// real rows arriving end the skeleton window immediately: a later
// transient empty list must not flash skeletons again
if (sessions.length > 0) setLoaded(true);
}, [sessions.length]);
const open = useCallback(
(id: string): void => {
navigate(`/s/${id}`);
+468 -365
View File
@@ -1,400 +1,503 @@
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();
});
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(
<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("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("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("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");
const { container } = render(
<Bubble msg={msg({ thinking: null })} tools={new Map()} />,
);
expect(container.querySelector(".thinking")).toBeNull();
});
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("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("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("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("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)}
/>
);
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("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();
it("filters repos by query, keyboard selects, empty filter message", async () => {
connectedMock();
render(tree(makeStore()));
await flush();
expect(screen.getByText("g/alpha")).toBeInTheDocument();
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();
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("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,
});
Object.defineProperty(scroller, "clientHeight", {
configurable: true,
value: 300,
});
it("keyboard Enter and Space on a repo item are default-prevented (C)", async () => {
connectedMock();
render(tree(makeStore()));
await flush();
const item = screen
.getByText("g/alpha")
.closest(".repo-item") as HTMLElement;
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);
const enter = fireEvent.keyDown(item, { key: "Enter" });
expect(enter).toBe(false); // preventDefault consumed: no page scroll
expect(screen.getByLabelText("Branch")).toHaveValue("main");
// 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);
const space = fireEvent.keyDown(item, { key: " " });
expect(space).toBe(false);
expect(item.getAttribute("aria-pressed")).toBe("true"); // still picks
// 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);
const tab = fireEvent.keyDown(item, { key: "Tab" });
expect(tab).toBe(true); // other keys keep their default behavior
});
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();
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();
});
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();
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("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());
describe("SpawnView spawn+poll", () => {
beforeEach(() => {
vi.useFakeTimers();
});
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()}
/>,
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.getByRole("button", { name: "Copy message" }),
screen.getByText("waiting for session to come online…"),
).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();
// 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(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("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("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("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();
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("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("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 [];
});
const store = makeStore({
spawnJobs: [
{ sessionId: "sp1", repo: "g/p", state: "building", containerId: "" },
],
});
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");
});
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 [];
});
const store = makeStore({
spawnJobs: [
{ sessionId: "sp2", repo: "g/p", state: "building", containerId: "" },
],
});
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
});
});
+5 -1
View File
@@ -270,7 +270,11 @@ export default function SpawnView({ store, pushToast }: Props) {
aria-pressed={selected?.path === r.path}
onClick={() => pick(r)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") pick(r);
if (e.key === "Enter" || e.key === " ") {
// role=button: keep Space from scrolling the page
e.preventDefault();
pick(r);
}
}}
>
<div style={{ minWidth: 0 }}>
+16
View File
@@ -38,6 +38,22 @@ describe("TaskPanel", () => {
expect(deleted.style.textDecoration).toContain("line-through");
});
it("duplicate todo contents render as distinct rows (D)", () => {
const tasks: TaskDerivation = {
...base,
todos: [
{ content: "same text", status: "pending", deleted: false },
{ content: "same text", status: "completed", deleted: false },
],
};
const { container } = render(<TaskPanel tasks={tasks} />);
// identical content would collide on key={content}; index-composite keys
// keep both rows
expect(container.querySelectorAll(".todo-item")).toHaveLength(2);
expect(container.querySelectorAll(".todo-icon")).toHaveLength(2);
expect(container.querySelector(".todo-icon.completed")).not.toBeNull();
});
it("subagent rows: running spinner, done check, failed cross", () => {
const tasks: TaskDerivation = {
...base,
+73 -64
View File
@@ -2,78 +2,87 @@ import type { SubagentRun, TodoItem, TodoStatus } from "./derive";
import type { TaskDerivation } from "./derive";
const STATUS_ICON: Record<TodoStatus, string> = {
pending: "○",
"in-progress": "◺",
completed: "●",
pending: "○",
"in-progress": "◺",
completed: "●",
};
function TodoRow({ item }: { item: TodoItem }) {
return (
<div className={`todo-item ${item.status === "completed" ? "done" : ""} ${item.deleted ? "deleted" : ""}`}>
<span className={`todo-icon ${item.status}`} aria-hidden="true">
{STATUS_ICON[item.status]}
</span>
<span className="todo-text" style={item.deleted ? { textDecoration: "line-through", color: "var(--text-faint)" } : undefined}>
{item.content}
</span>
</div>
);
return (
<div
className={`todo-item ${item.status === "completed" ? "done" : ""} ${item.deleted ? "deleted" : ""}`}
>
<span className={`todo-icon ${item.status}`} aria-hidden="true">
{STATUS_ICON[item.status]}
</span>
<span
className="todo-text"
style={
item.deleted
? { textDecoration: "line-through", color: "var(--text-faint)" }
: undefined
}
>
{item.content}
</span>
</div>
);
}
function SubagentRow({ run }: { run: SubagentRun }) {
return (
<div className="subagent-item">
{run.running ? (
<span className="spinner" role="status" aria-label="running" />
) : (
<span className="done-icon" aria-hidden="true">
{run.isError ? "✕" : "✓"}
</span>
)}
<span>{run.name}</span>
<span style={{ color: "var(--text-faint)", fontSize: 11 }}>
{run.running ? "running" : run.isError ? "failed" : "done"}
</span>
</div>
);
return (
<div className="subagent-item">
{run.running ? (
<span className="spinner" role="status" aria-label="running" />
) : (
<span className="done-icon" aria-hidden="true">
{run.isError ? "✕" : "✓"}
</span>
)}
<span>{run.name}</span>
<span style={{ color: "var(--text-faint)", fontSize: 11 }}>
{run.running ? "running" : run.isError ? "failed" : "done"}
</span>
</div>
);
}
export default function TaskPanel({ tasks }: { tasks: TaskDerivation }) {
const hasTodos: boolean = tasks.todos.length > 0;
const hasSubagents: boolean = tasks.subagents.length > 0;
const hasWorking: boolean = tasks.workingTools.length > 0;
const empty: boolean = !hasTodos && !hasSubagents && !hasWorking;
const hasTodos: boolean = tasks.todos.length > 0;
const hasSubagents: boolean = tasks.subagents.length > 0;
const hasWorking: boolean = tasks.workingTools.length > 0;
const empty: boolean = !hasTodos && !hasSubagents && !hasWorking;
return (
<div className="task-panel">
{empty && <p className="empty">No tasks yet.</p>}
{hasTodos && (
<section className="task-section">
<h2>Tasks</h2>
{tasks.todos.map((t) => (
<TodoRow key={t.content} item={t} />
))}
</section>
)}
{hasSubagents && (
<section className="task-section">
<h2>Subagents</h2>
{tasks.subagents.map((s) => (
<SubagentRow key={s.key} run={s} />
))}
</section>
)}
{hasWorking && (
<section className="task-section">
<h2>Working</h2>
{tasks.workingTools.map((w) => (
<div key={w.id} className="working-line">
<span className="spinner" role="status" aria-label="working" />
{w.name}
</div>
))}
</section>
)}
</div>
);
return (
<div className="task-panel">
{empty && <p className="empty">No tasks yet.</p>}
{hasTodos && (
<section className="task-section">
<h2>Tasks</h2>
{tasks.todos.map((t, i) => (
<TodoRow key={`${i}-${t.content}`} item={t} />
))}
</section>
)}
{hasSubagents && (
<section className="task-section">
<h2>Subagents</h2>
{tasks.subagents.map((s) => (
<SubagentRow key={s.key} run={s} />
))}
</section>
)}
{hasWorking && (
<section className="task-section">
<h2>Working</h2>
{tasks.workingTools.map((w) => (
<div key={w.id} className="working-line">
<span className="spinner" role="status" aria-label="working" />
{w.name}
</div>
))}
</section>
)}
</div>
);
}
+29 -11
View File
@@ -69,10 +69,28 @@ function listEvent(seq: number): EventFrame {
describe("useSessions", () => {
it("returns null when unconfigured", () => {
const push = vi.fn();
const { result } = renderHook(() => useSessions(push));
const { result } = renderHook(() => useSessions(push, true));
expect(result.current).toBeNull();
});
it("first-run: inactive -> active starts the ws manager (A)", async () => {
seedSettings();
mockFetchJson(() => []);
const push = vi.fn();
const { result, rerender } = renderHook(
({ active }: { active: boolean }) => useSessions(push, active),
{ initialProps: { active: false } },
);
// gate is up: settings exist but the hook must not dial yet
expect(FakeWebSocket.instances.length).toBe(0);
expect(result.current).not.toBeNull();
// gate saved: active flips true, the effect re-runs and starts the manager
rerender({ active: true });
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
expect(result.current?.state).toBe("connecting");
});
it("seeds via REST, updates from session_list and spawn_status frames, refresh works", async () => {
seedSettings();
const sessions: SessionListItem[] = [
@@ -88,7 +106,7 @@ describe("useSessions", () => {
return [];
});
const push = vi.fn();
const { result } = renderHook(() => useSessions(push));
const { result } = renderHook(() => useSessions(push, true));
expect(result.current?.state).toBe("connecting");
await waitFor(() => expect(result.current?.sessions).toEqual(sessions));
@@ -131,7 +149,7 @@ describe("useSessions", () => {
throw new Error("network down");
});
const push = vi.fn();
const { result } = renderHook(() => useSessions(push));
const { result } = renderHook(() => useSessions(push, true));
await waitFor(() =>
expect(push).toHaveBeenCalledWith("sessions: network down"),
);
@@ -147,7 +165,7 @@ describe("useSessions", () => {
seedSettings();
mockFetchJson(() => []);
const push = vi.fn();
const { result } = renderHook(() => useSessions(push));
const { result } = renderHook(() => useSessions(push, true));
const sock = FakeWebSocket.last();
act(() => sock.serverOpen());
@@ -198,7 +216,7 @@ describe("useSessions", () => {
mockFetchJson(() => []);
const loc = stubReload();
const push = vi.fn();
const { result } = renderHook(() => useSessions(push));
const { result } = renderHook(() => useSessions(push, true));
const sock = FakeWebSocket.last();
act(() => sock.serverOpen());
act(() => sock.serverClose(1008));
@@ -219,7 +237,7 @@ describe("useSessions", () => {
);
const loc = stubReload();
const push = vi.fn();
renderHook(() => useSessions(push));
renderHook(() => useSessions(push, true));
const failConnect = (): void => {
act(() => FakeWebSocket.last().serverClose(1006));
};
@@ -254,7 +272,7 @@ describe("useSessions", () => {
});
const loc = stubReload();
const push = vi.fn();
renderHook(() => useSessions(push));
renderHook(() => useSessions(push, true));
const failConnect = (): void => {
act(() => FakeWebSocket.last().serverClose(1006));
};
@@ -293,7 +311,7 @@ describe("useSessions", () => {
});
const loc = stubReload();
const push = vi.fn();
renderHook(() => useSessions(push));
renderHook(() => useSessions(push, true));
const failConnect = (): void => {
act(() => FakeWebSocket.last().serverClose(1006));
};
@@ -336,7 +354,7 @@ describe("useSessions", () => {
seedSettings();
mockFetchJson(() => []);
const push = vi.fn();
const { unmount } = renderHook(() => useSessions(push));
const { unmount } = renderHook(() => useSessions(push, true));
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
const sock = FakeWebSocket.last();
unmount();
@@ -350,7 +368,7 @@ describe("useSessions", () => {
const stable: SessionListItem[] = [];
mockFetchJson(() => stable);
const push = vi.fn();
const { result, rerender } = renderHook(() => useSessions(push));
const { result, rerender } = renderHook(() => useSessions(push, true));
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
// let the mount-time seed settle
for (let i = 0; i < 4; i += 1) {
@@ -370,7 +388,7 @@ describe("useSessions", () => {
let list: SessionListItem[] = [];
mockFetchJson(() => list);
const push = vi.fn();
const { result } = renderHook(() => useSessions(push));
const { result } = renderHook(() => useSessions(push, true));
await waitFor(() => expect(result.current).not.toBeNull());
const first = result.current;
const sock = FakeWebSocket.last();
+7 -1
View File
@@ -71,8 +71,13 @@ export interface SessionsStore {
) => () => void;
}
/**
* `active` tells the hook the settings gate has been passed; it must be a
* dep below so saving settings (false -> true) starts the ws manager.
*/
export function useSessions(
pushToast: (text: string) => void,
active: boolean,
): SessionsStore | null {
const [sessions, setSessions] = useState<SessionListItem[]>([]);
const [spawnJobs, setSpawnJobs] = useState<SpawnJob[]>([]);
@@ -80,6 +85,7 @@ export function useSessions(
const [manager, setManager] = useState<WsManager | null>(null);
useEffect(() => {
if (!active) return;
const settings = getSettings();
if (settings === null) return;
@@ -136,7 +142,7 @@ export function useSessions(
m.close();
setManager(null);
};
}, [pushToast]);
}, [pushToast, active]);
const refresh = useCallback(async (): Promise<SessionListItem[]> => {
try {