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:
+85
-82
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user