summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/App.test.ts984
-rw-r--r--src/app/ErrorModal.svelte271
-rw-r--r--src/app/resolve-http-url.test.ts102
-rw-r--r--src/app/resolve-http-url.ts30
-rw-r--r--src/app/resolve-ws-url.test.ts96
-rw-r--r--src/app/resolve-ws-url.ts28
-rw-r--r--src/app/store.svelte.ts3412
-rw-r--r--src/app/store.test.ts2676
-rw-r--r--src/app/uuid.test.ts44
-rw-r--r--src/app/uuid.ts92
10 files changed, 3867 insertions, 3868 deletions
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index 1a93948..7c0a851 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -8,517 +8,517 @@ import App from "./App.svelte";
import { createAppStore } from "./store.svelte";
interface FakeSocket extends WebSocketLike {
- sent: string[];
- resolveOpen(): void;
- feedServerMessage(data: WsServerMessage): void;
- feedSurfaceMessage(data: SurfaceServerMessage): void;
+ sent: string[];
+ resolveOpen(): void;
+ feedServerMessage(data: WsServerMessage): void;
+ feedSurfaceMessage(data: SurfaceServerMessage): void;
}
function fakeSocket(): FakeSocket {
- let onopen: (() => void) | null = null;
- let onmessage: ((ev: { data: string }) => void) | null = null;
- const sent: string[] = [];
-
- const ws: FakeSocket = {
- send(data: string) {
- sent.push(data);
- },
- close() {},
- get onopen() {
- return onopen;
- },
- set onopen(fn) {
- onopen = fn;
- },
- get onmessage() {
- return onmessage;
- },
- set onmessage(fn) {
- onmessage = fn;
- },
- get onclose() {
- return null;
- },
- set onclose(_fn) {},
- resolveOpen() {
- onopen?.();
- },
- feedServerMessage(msg: WsServerMessage) {
- onmessage?.({ data: JSON.stringify(msg) });
- },
- feedSurfaceMessage(msg: SurfaceServerMessage) {
- onmessage?.({ data: JSON.stringify(msg) });
- },
- sent,
- };
- return ws;
+ let onopen: (() => void) | null = null;
+ let onmessage: ((ev: { data: string }) => void) | null = null;
+ const sent: string[] = [];
+
+ const ws: FakeSocket = {
+ send(data: string) {
+ sent.push(data);
+ },
+ close() {},
+ get onopen() {
+ return onopen;
+ },
+ set onopen(fn) {
+ onopen = fn;
+ },
+ get onmessage() {
+ return onmessage;
+ },
+ set onmessage(fn) {
+ onmessage = fn;
+ },
+ get onclose() {
+ return null;
+ },
+ set onclose(_fn) {},
+ resolveOpen() {
+ onopen?.();
+ },
+ feedServerMessage(msg: WsServerMessage) {
+ onmessage?.({ data: JSON.stringify(msg) });
+ },
+ feedSurfaceMessage(msg: SurfaceServerMessage) {
+ onmessage?.({ data: JSON.stringify(msg) });
+ },
+ sent,
+ };
+ return ws;
}
function fakeFetchImpl(): typeof fetch {
- return async (input: string | URL | Request): Promise<Response> => {
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
- status: 200,
- });
- }
- if (url.includes("/conversations?status=")) {
- return new Response(JSON.stringify({ conversations: [] }), { status: 200 });
- }
- if (url.endsWith("/cwd")) {
- return new Response(JSON.stringify({ conversationId: "c", cwd: null }), { status: 200 });
- }
- if (url.endsWith("/lsp")) {
- return new Response(JSON.stringify({ conversationId: "c", cwd: null, servers: [] }), {
- status: 200,
- });
- }
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
- };
+ return async (input: string | URL | Request): Promise<Response> => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/models")) {
+ return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ status: 200,
+ });
+ }
+ if (url.includes("/conversations?status=")) {
+ return new Response(JSON.stringify({ conversations: [] }), { status: 200 });
+ }
+ if (url.endsWith("/cwd")) {
+ return new Response(JSON.stringify({ conversationId: "c", cwd: null }), { status: 200 });
+ }
+ if (url.endsWith("/lsp")) {
+ return new Response(JSON.stringify({ conversationId: "c", cwd: null, servers: [] }), {
+ status: 200,
+ });
+ }
+ return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ };
}
function createFakeStorage(): Storage {
- const map = new Map<string, string>();
- return {
- get length() {
- return map.size;
- },
- clear() {
- map.clear();
- },
- getItem(key: string): string | null {
- return map.get(key) ?? null;
- },
- key(_index: number): string | null {
- return null;
- },
- removeItem(key: string) {
- map.delete(key);
- },
- setItem(key: string, value: string) {
- map.set(key, value);
- },
- };
+ const map = new Map<string, string>();
+ return {
+ get length() {
+ return map.size;
+ },
+ clear() {
+ map.clear();
+ },
+ getItem(key: string): string | null {
+ return map.get(key) ?? null;
+ },
+ key(_index: number): string | null {
+ return null;
+ },
+ removeItem(key: string) {
+ map.delete(key);
+ },
+ setItem(key: string, value: string) {
+ map.set(key, value);
+ },
+ };
}
function createFakeStorageWithViews(views: readonly string[] = ["extensions"]): Storage {
- const storage = createFakeStorage();
- storage.setItem("dispatch.sidebar.views", JSON.stringify(views));
- return storage;
+ const storage = createFakeStorage();
+ storage.setItem("dispatch.sidebar.views", JSON.stringify(views));
+ return storage;
}
function sentMessages(ws: FakeSocket) {
- return ws.sent.map((s) => JSON.parse(s));
+ return ws.sent.map((s) => JSON.parse(s));
}
function activeConversationId(store: ReturnType<typeof createAppStore>): string {
- const id = store.activeConversationId;
- expect(id).not.toBeNull();
- return id as string;
+ const id = store.activeConversationId;
+ expect(id).not.toBeNull();
+ return id as string;
}
describe("App component interaction tests", () => {
- it("renders the model selector and composer in draft mode", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- render(App, { props: { store } });
-
- expect(screen.getByRole("textbox", { name: "Message input" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument();
- expect(screen.getByRole("combobox", { name: "Model selector" })).toBeInTheDocument();
-
- store.dispose();
- });
-
- it("auto-subscribes to every catalog entry on render (no buttons to click)", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.sent.length = 0;
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [
- { id: "s1", region: "sidebar", title: "Surface One" },
- { id: "s2", region: "panel", title: "Surface Two" },
- ],
- });
-
- render(App, { props: { store } });
-
- const subscribed = sentMessages(ws)
- .filter((m: { type: string }) => m.type === "subscribe")
- .map((m: { surfaceId: string }) => m.surfaceId);
- expect(subscribed).toContain("s1");
- expect(subscribed).toContain("s2");
-
- store.dispose();
- });
-
- it("renders every surface expanded once their specs arrive", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorageWithViews(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [
- { id: "s1", region: "sidebar", title: "Surface One" },
- { id: "s2", region: "panel", title: "Surface Two" },
- ],
- });
-
- render(App, { props: { store } });
-
- // No interaction: specs arrive and both surfaces render expanded.
- ws.feedSurfaceMessage({
- type: "surface",
- spec: {
- id: "s1",
- region: "sidebar",
- title: "Surface One",
- fields: [{ kind: "stat", label: "Tokens", value: "1,234" }],
- },
- });
- ws.feedSurfaceMessage({
- type: "surface",
- spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] },
- });
-
- expect(await screen.findByRole("heading", { name: "Surface One" })).toBeInTheDocument();
- expect(await screen.findByRole("heading", { name: "Surface Two" })).toBeInTheDocument();
- expect(await screen.findByText("Tokens")).toBeInTheDocument();
- expect(await screen.findByText("1,234")).toBeInTheDocument();
-
- store.dispose();
- });
-
- it("an error message renders the alert banner", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "error",
- message: "Something went wrong",
- });
-
- render(App, { props: { store } });
-
- const alert = screen.getByRole("alert");
- expect(alert).toHaveTextContent("Something went wrong");
-
- store.dispose();
- });
-
- it("invoking a field action sends an invoke", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorageWithViews(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
- });
-
- render(App, { props: { store } });
-
- const user = userEvent.setup();
- // Surface is auto-subscribed; its spec arrives and renders expanded.
- ws.feedSurfaceMessage({
- type: "surface",
- spec: {
- id: "s1",
- region: "sidebar",
- title: "Surface One",
- fields: [
- {
- kind: "toggle",
- label: "Dark Mode",
- value: false,
- action: { actionId: "toggle-dark" },
- },
- ],
- },
- });
-
- ws.sent.length = 0;
- const checkbox = await screen.findByRole("checkbox", { name: "Dark Mode" });
- await user.click(checkbox);
-
- const msgs = sentMessages(ws);
- const invoke = msgs.find(
- (m: { type: string; surfaceId: string; actionId: string; payload: unknown }) =>
- m.type === "invoke" &&
- m.surfaceId === "s1" &&
- m.actionId === "toggle-dark" &&
- m.payload === true,
- );
- expect(invoke).toBeTruthy();
-
- store.dispose();
- });
-
- it("typing and sending a message posts chat.send on the socket", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- render(App, { props: { store } });
-
- const user = userEvent.setup();
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, "hello from UI");
-
- ws.sent.length = 0;
- const sendBtn = screen.getByRole("button", { name: "Send" });
- await user.click(sendBtn);
-
- const msgs = sentMessages(ws);
- const chatSend = msgs.find((m: { type: string }) => m.type === "chat.send") as
- | { type: string; conversationId: string; message: string }
- | undefined;
- expect(chatSend).toBeTruthy();
- expect(chatSend?.message).toBe("hello from UI");
-
- store.dispose();
- });
-
- it("incoming chat.delta renders text in the chat transcript", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- // Promote draft to tab
- store.send("test");
- const convId = activeConversationId(store);
-
- render(App, { props: { store } });
-
- ws.feedServerMessage({
- type: "chat.delta",
- event: {
- type: "turn-start",
- conversationId: convId,
- turnId: "turn-1",
- },
- });
-
- ws.feedServerMessage({
- type: "chat.delta",
- event: {
- type: "text-delta",
- conversationId: convId,
- turnId: "turn-1",
- delta: "Hi there!",
- },
- });
-
- expect(await screen.findByText("Hi there!")).toBeInTheDocument();
-
- store.dispose();
- });
-
- it("renders a custom 'table' field of a surface as a table", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorageWithViews(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
- });
-
- render(App, { props: { store } });
-
- // Auto-subscribed; the custom-table spec arrives and renders expanded.
- ws.feedSurfaceMessage({
- type: "surface",
- spec: {
- id: "s1",
- region: "sidebar",
- title: "Surface One",
- fields: [
- {
- kind: "custom",
- rendererId: "table",
- payload: {
- columns: ["Name", "Scope"],
- rows: [["cache-warm", "backend"]],
- },
- },
- ],
- },
- });
-
- expect(await screen.findByRole("columnheader", { name: "Name" })).toBeInTheDocument();
- expect(await screen.findByText("cache-warm")).toBeInTheDocument();
- expect(await screen.findByText("backend")).toBeInTheDocument();
-
- store.dispose();
- });
-
- it("the Extensions view lists frontend modules aggregated from feature manifests", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorageWithViews(),
- });
- ws.resolveOpen();
-
- render(App, { props: { store } });
-
- // Extensions view is pre-populated in the fake storage, so the modules table renders immediately.
- expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument();
- for (const name of [
- "chat",
- "tabs",
- "surface-host",
- "views",
- "conversation-cache",
- "markdown",
- ]) {
- expect(screen.getByRole("cell", { name })).toBeInTheDocument();
- }
-
- store.dispose();
- });
-
- it("shows a full-screen error modal when fetchOpenConversations fails", async () => {
- // A fetch that throws for the conversations list endpoint (simulating a
- // network failure / unreachable backend on a new device).
- const failingFetch = async (input: string | URL | Request): Promise<Response> => {
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
- status: 200,
- });
- }
- if (url.includes("/conversations?status=")) {
- throw new TypeError("Failed to fetch: network error");
- }
- if (url.endsWith("/cwd")) {
- return new Response(JSON.stringify({ conversationId: "c", cwd: null }), { status: 200 });
- }
- if (url.endsWith("/lsp")) {
- return new Response(JSON.stringify({ conversationId: "c", cwd: null, servers: [] }), {
- status: 200,
- });
- }
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
- };
-
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: failingFetch,
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- render(App, { props: { store } });
-
- // The modal should appear with the error text
- const dialog = await screen.findByRole("dialog", { name: "Error" }, { timeout: 3000 });
- expect(dialog).toBeInTheDocument();
- expect(dialog).toHaveTextContent("Failed to load conversations");
- expect(dialog).toHaveTextContent("TypeError");
- expect(dialog).toHaveTextContent("network error");
-
- // Dismiss button clears the modal
- const dismissBtn = screen.getByRole("button", { name: "Dismiss error" });
- await userEvent.setup().click(dismissBtn);
- expect(store.fatalError).toBeNull();
-
- store.dispose();
- });
-
- it("does not show the error modal when fetchOpenConversations succeeds", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(), // returns valid empty conversations list
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- render(App, { props: { store } });
-
- // Wait a tick for boot async to settle
- await new Promise((resolve) => setTimeout(resolve, 200));
-
- expect(store.fatalError).toBeNull();
-
- store.dispose();
- });
-
- it("sends workspaceId when setting cwd", async () => {
- let capturedBody: SetCwdRequest | undefined;
- const fetchWithCapture = async (
- input: string | URL | Request,
- init?: RequestInit,
- ): Promise<Response> => {
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/cwd") && init?.method === "PUT") {
- capturedBody = JSON.parse(init.body as string) as SetCwdRequest;
- return new Response(JSON.stringify({ conversationId: "c", cwd: capturedBody.cwd }), {
- status: 200,
- });
- }
- return fakeFetchImpl()(input);
- };
-
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fetchWithCapture,
- localStorage: createFakeStorage(),
- workspaceId: "my-team",
- });
- ws.resolveOpen();
-
- // Let async boot settle before mutating cwd.
- await new Promise((resolve) => setTimeout(resolve, 50));
-
- const result = await store.setCwd("arch-rewrite");
- expect(result).toMatchObject({ ok: true, cwd: "arch-rewrite" });
- expect(capturedBody).toEqual({ cwd: "arch-rewrite", workspaceId: "my-team" });
-
- store.dispose();
- });
+ it("renders the model selector and composer in draft mode", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ render(App, { props: { store } });
+
+ expect(screen.getByRole("textbox", { name: "Message input" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument();
+ expect(screen.getByRole("combobox", { name: "Model selector" })).toBeInTheDocument();
+
+ store.dispose();
+ });
+
+ it("auto-subscribes to every catalog entry on render (no buttons to click)", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.sent.length = 0;
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "s1", region: "sidebar", title: "Surface One" },
+ { id: "s2", region: "panel", title: "Surface Two" },
+ ],
+ });
+
+ render(App, { props: { store } });
+
+ const subscribed = sentMessages(ws)
+ .filter((m: { type: string }) => m.type === "subscribe")
+ .map((m: { surfaceId: string }) => m.surfaceId);
+ expect(subscribed).toContain("s1");
+ expect(subscribed).toContain("s2");
+
+ store.dispose();
+ });
+
+ it("renders every surface expanded once their specs arrive", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorageWithViews(),
+ });
+ ws.resolveOpen();
+
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "s1", region: "sidebar", title: "Surface One" },
+ { id: "s2", region: "panel", title: "Surface Two" },
+ ],
+ });
+
+ render(App, { props: { store } });
+
+ // No interaction: specs arrive and both surfaces render expanded.
+ ws.feedSurfaceMessage({
+ type: "surface",
+ spec: {
+ id: "s1",
+ region: "sidebar",
+ title: "Surface One",
+ fields: [{ kind: "stat", label: "Tokens", value: "1,234" }],
+ },
+ });
+ ws.feedSurfaceMessage({
+ type: "surface",
+ spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] },
+ });
+
+ expect(await screen.findByRole("heading", { name: "Surface One" })).toBeInTheDocument();
+ expect(await screen.findByRole("heading", { name: "Surface Two" })).toBeInTheDocument();
+ expect(await screen.findByText("Tokens")).toBeInTheDocument();
+ expect(await screen.findByText("1,234")).toBeInTheDocument();
+
+ store.dispose();
+ });
+
+ it("an error message renders the alert banner", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.feedSurfaceMessage({
+ type: "error",
+ message: "Something went wrong",
+ });
+
+ render(App, { props: { store } });
+
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveTextContent("Something went wrong");
+
+ store.dispose();
+ });
+
+ it("invoking a field action sends an invoke", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorageWithViews(),
+ });
+ ws.resolveOpen();
+
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
+ });
+
+ render(App, { props: { store } });
+
+ const user = userEvent.setup();
+ // Surface is auto-subscribed; its spec arrives and renders expanded.
+ ws.feedSurfaceMessage({
+ type: "surface",
+ spec: {
+ id: "s1",
+ region: "sidebar",
+ title: "Surface One",
+ fields: [
+ {
+ kind: "toggle",
+ label: "Dark Mode",
+ value: false,
+ action: { actionId: "toggle-dark" },
+ },
+ ],
+ },
+ });
+
+ ws.sent.length = 0;
+ const checkbox = await screen.findByRole("checkbox", { name: "Dark Mode" });
+ await user.click(checkbox);
+
+ const msgs = sentMessages(ws);
+ const invoke = msgs.find(
+ (m: { type: string; surfaceId: string; actionId: string; payload: unknown }) =>
+ m.type === "invoke" &&
+ m.surfaceId === "s1" &&
+ m.actionId === "toggle-dark" &&
+ m.payload === true,
+ );
+ expect(invoke).toBeTruthy();
+
+ store.dispose();
+ });
+
+ it("typing and sending a message posts chat.send on the socket", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ render(App, { props: { store } });
+
+ const user = userEvent.setup();
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "hello from UI");
+
+ ws.sent.length = 0;
+ const sendBtn = screen.getByRole("button", { name: "Send" });
+ await user.click(sendBtn);
+
+ const msgs = sentMessages(ws);
+ const chatSend = msgs.find((m: { type: string }) => m.type === "chat.send") as
+ | { type: string; conversationId: string; message: string }
+ | undefined;
+ expect(chatSend).toBeTruthy();
+ expect(chatSend?.message).toBe("hello from UI");
+
+ store.dispose();
+ });
+
+ it("incoming chat.delta renders text in the chat transcript", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // Promote draft to tab
+ store.send("test");
+ const convId = activeConversationId(store);
+
+ render(App, { props: { store } });
+
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: {
+ type: "turn-start",
+ conversationId: convId,
+ turnId: "turn-1",
+ },
+ });
+
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: {
+ type: "text-delta",
+ conversationId: convId,
+ turnId: "turn-1",
+ delta: "Hi there!",
+ },
+ });
+
+ expect(await screen.findByText("Hi there!")).toBeInTheDocument();
+
+ store.dispose();
+ });
+
+ it("renders a custom 'table' field of a surface as a table", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorageWithViews(),
+ });
+ ws.resolveOpen();
+
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
+ });
+
+ render(App, { props: { store } });
+
+ // Auto-subscribed; the custom-table spec arrives and renders expanded.
+ ws.feedSurfaceMessage({
+ type: "surface",
+ spec: {
+ id: "s1",
+ region: "sidebar",
+ title: "Surface One",
+ fields: [
+ {
+ kind: "custom",
+ rendererId: "table",
+ payload: {
+ columns: ["Name", "Scope"],
+ rows: [["cache-warm", "backend"]],
+ },
+ },
+ ],
+ },
+ });
+
+ expect(await screen.findByRole("columnheader", { name: "Name" })).toBeInTheDocument();
+ expect(await screen.findByText("cache-warm")).toBeInTheDocument();
+ expect(await screen.findByText("backend")).toBeInTheDocument();
+
+ store.dispose();
+ });
+
+ it("the Extensions view lists frontend modules aggregated from feature manifests", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorageWithViews(),
+ });
+ ws.resolveOpen();
+
+ render(App, { props: { store } });
+
+ // Extensions view is pre-populated in the fake storage, so the modules table renders immediately.
+ expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument();
+ for (const name of [
+ "chat",
+ "tabs",
+ "surface-host",
+ "views",
+ "conversation-cache",
+ "markdown",
+ ]) {
+ expect(screen.getByRole("cell", { name })).toBeInTheDocument();
+ }
+
+ store.dispose();
+ });
+
+ it("shows a full-screen error modal when fetchOpenConversations fails", async () => {
+ // A fetch that throws for the conversations list endpoint (simulating a
+ // network failure / unreachable backend on a new device).
+ const failingFetch = async (input: string | URL | Request): Promise<Response> => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/models")) {
+ return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ status: 200,
+ });
+ }
+ if (url.includes("/conversations?status=")) {
+ throw new TypeError("Failed to fetch: network error");
+ }
+ if (url.endsWith("/cwd")) {
+ return new Response(JSON.stringify({ conversationId: "c", cwd: null }), { status: 200 });
+ }
+ if (url.endsWith("/lsp")) {
+ return new Response(JSON.stringify({ conversationId: "c", cwd: null, servers: [] }), {
+ status: 200,
+ });
+ }
+ return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ };
+
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: failingFetch,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ render(App, { props: { store } });
+
+ // The modal should appear with the error text
+ const dialog = await screen.findByRole("dialog", { name: "Error" }, { timeout: 3000 });
+ expect(dialog).toBeInTheDocument();
+ expect(dialog).toHaveTextContent("Failed to load conversations");
+ expect(dialog).toHaveTextContent("TypeError");
+ expect(dialog).toHaveTextContent("network error");
+
+ // Dismiss button clears the modal
+ const dismissBtn = screen.getByRole("button", { name: "Dismiss error" });
+ await userEvent.setup().click(dismissBtn);
+ expect(store.fatalError).toBeNull();
+
+ store.dispose();
+ });
+
+ it("does not show the error modal when fetchOpenConversations succeeds", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(), // returns valid empty conversations list
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ render(App, { props: { store } });
+
+ // Wait a tick for boot async to settle
+ await new Promise((resolve) => setTimeout(resolve, 200));
+
+ expect(store.fatalError).toBeNull();
+
+ store.dispose();
+ });
+
+ it("sends workspaceId when setting cwd", async () => {
+ let capturedBody: SetCwdRequest | undefined;
+ const fetchWithCapture = async (
+ input: string | URL | Request,
+ init?: RequestInit,
+ ): Promise<Response> => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/cwd") && init?.method === "PUT") {
+ capturedBody = JSON.parse(init.body as string) as SetCwdRequest;
+ return new Response(JSON.stringify({ conversationId: "c", cwd: capturedBody.cwd }), {
+ status: 200,
+ });
+ }
+ return fakeFetchImpl()(input);
+ };
+
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fetchWithCapture,
+ localStorage: createFakeStorage(),
+ workspaceId: "my-team",
+ });
+ ws.resolveOpen();
+
+ // Let async boot settle before mutating cwd.
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ const result = await store.setCwd("arch-rewrite");
+ expect(result).toMatchObject({ ok: true, cwd: "arch-rewrite" });
+ expect(capturedBody).toEqual({ cwd: "arch-rewrite", workspaceId: "my-team" });
+
+ store.dispose();
+ });
});
diff --git a/src/app/ErrorModal.svelte b/src/app/ErrorModal.svelte
index 7fc09a6..0415827 100644
--- a/src/app/ErrorModal.svelte
+++ b/src/app/ErrorModal.svelte
@@ -1,50 +1,50 @@
<script lang="ts">
- /**
- * Full-screen error modal — surfaces critical errors that would otherwise be
- * silently swallowed (e.g. the cross-device tab-restore fetch failing). Shows
- * the full error text + stack trace in a scrollable `<pre>`, a Copy button
- * (clipboard), and an X to dismiss. Pure presentation: the error string and
- * dismiss callback are injected as props.
- */
- let {
- error,
- onDismiss,
- }: {
- error: string;
- onDismiss: () => void;
- } = $props();
+ /**
+ * Full-screen error modal — surfaces critical errors that would otherwise be
+ * silently swallowed (e.g. the cross-device tab-restore fetch failing). Shows
+ * the full error text + stack trace in a scrollable `<pre>`, a Copy button
+ * (clipboard), and an X to dismiss. Pure presentation: the error string and
+ * dismiss callback are injected as props.
+ */
+ let {
+ error,
+ onDismiss,
+ }: {
+ error: string;
+ onDismiss: () => void;
+ } = $props();
- let copied = $state(false);
- let copyTimer: ReturnType<typeof setTimeout> | undefined;
+ let copied = $state(false);
+ let copyTimer: ReturnType<typeof setTimeout> | undefined;
- async function handleCopy(): Promise<void> {
- try {
- await navigator.clipboard.writeText(error);
- copied = true;
- clearTimeout(copyTimer);
- copyTimer = setTimeout(() => {
- copied = false;
- }, 2000);
- } catch {
- // Clipboard API may be unavailable (non-secure context). Fallback:
- // select the text so the user can Ctrl+C manually.
- const pre = document.getElementById("error-modal-text");
- if (pre !== null) {
- const range = document.createRange();
- range.selectNodeContents(pre);
- const selection = window.getSelection();
- selection?.removeAllRanges();
- selection?.addRange(range);
- }
- }
- }
+ async function handleCopy(): Promise<void> {
+ try {
+ await navigator.clipboard.writeText(error);
+ copied = true;
+ clearTimeout(copyTimer);
+ copyTimer = setTimeout(() => {
+ copied = false;
+ }, 2000);
+ } catch {
+ // Clipboard API may be unavailable (non-secure context). Fallback:
+ // select the text so the user can Ctrl+C manually.
+ const pre = document.getElementById("error-modal-text");
+ if (pre !== null) {
+ const range = document.createRange();
+ range.selectNodeContents(pre);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ }
+ }
+ }
- function handleKeydown(event: KeyboardEvent): void {
- if (event.key === "Escape") {
- event.preventDefault();
- onDismiss();
- }
- }
+ function handleKeydown(event: KeyboardEvent): void {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ onDismiss();
+ }
+ }
</script>
<svelte:window onkeydown={handleKeydown} />
@@ -52,100 +52,99 @@
<!-- Full-screen overlay: fixed, high z-index, semi-transparent backdrop. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
- class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
- role="dialog"
- aria-modal="true"
- aria-label="Error"
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
+ role="dialog"
+ aria-modal="true"
+ aria-label="Error"
>
- <div class="flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-base-100 shadow-xl">
- <!-- Header -->
- <div class="flex items-center justify-between border-b border-base-300 px-4 py-3">
- <div class="flex items-center gap-2">
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="size-5 text-error"
- aria-hidden="true"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
- />
- </svg>
- <h2 class="text-lg font-semibold text-error">Something went wrong</h2>
- </div>
- <button
- type="button"
- class="btn btn-ghost btn-sm btn-square"
- aria-label="Dismiss error"
- onclick={onDismiss}
- >
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="size-5"
- aria-hidden="true"
- >
- <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
- </svg>
- </button>
- </div>
+ <div class="flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-base-100 shadow-xl">
+ <!-- Header -->
+ <div class="flex items-center justify-between border-b border-base-300 px-4 py-3">
+ <div class="flex items-center gap-2">
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-5 text-error"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
+ />
+ </svg>
+ <h2 class="text-lg font-semibold text-error">Something went wrong</h2>
+ </div>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm btn-square"
+ aria-label="Dismiss error"
+ onclick={onDismiss}
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-5"
+ aria-hidden="true"
+ >
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
- <!-- Body: scrollable error text -->
- <div class="overflow-auto p-4">
- <pre
- id="error-modal-text"
- class="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed opacity-80"
- >{error}</pre>
- </div>
+ <!-- Body: scrollable error text -->
+ <div class="overflow-auto p-4">
+ <pre
+ id="error-modal-text"
+ class="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed opacity-80">{error}</pre>
+ </div>
- <!-- Footer: copy button -->
- <div class="flex items-center justify-between gap-2 border-t border-base-300 px-4 py-3">
- <span class="text-xs opacity-50">Press Esc to dismiss</span>
- <button
- type="button"
- class="btn btn-sm {copied ? 'btn-success' : 'btn-outline'}"
- onclick={handleCopy}
- >
- {#if copied}
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="size-4"
- aria-hidden="true"
- >
- <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
- </svg>
- Copied
- {:else}
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="size-4"
- aria-hidden="true"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m18 3.75h-3.75m3.75 0-2.25 2.25m2.25-2.25 2.25 2.25M4.5 6.75 6.75 4.5"
- />
- </svg>
- Copy
- {/if}
- </button>
- </div>
- </div>
+ <!-- Footer: copy button -->
+ <div class="flex items-center justify-between gap-2 border-t border-base-300 px-4 py-3">
+ <span class="text-xs opacity-50">Press Esc to dismiss</span>
+ <button
+ type="button"
+ class="btn btn-sm {copied ? 'btn-success' : 'btn-outline'}"
+ onclick={handleCopy}
+ >
+ {#if copied}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-4"
+ aria-hidden="true"
+ >
+ <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
+ </svg>
+ Copied
+ {:else}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-4"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m18 3.75h-3.75m3.75 0-2.25 2.25m2.25-2.25 2.25 2.25M4.5 6.75 6.75 4.5"
+ />
+ </svg>
+ Copy
+ {/if}
+ </button>
+ </div>
+ </div>
</div>
diff --git a/src/app/resolve-http-url.test.ts b/src/app/resolve-http-url.test.ts
index 90edcbb..0da2591 100644
--- a/src/app/resolve-http-url.test.ts
+++ b/src/app/resolve-http-url.test.ts
@@ -2,55 +2,55 @@ import { describe, expect, it } from "vitest";
import { resolveHttpUrl } from "./resolve-http-url";
describe("resolveHttpUrl", () => {
- it("explicit url wins over everything", () => {
- const result = resolveHttpUrl(
- { VITE_HTTP_URL: "https://env.example.com:9999" },
- { protocol: "https:", hostname: "page.example.com" },
- );
- expect(result).toBe("https://env.example.com:9999");
- });
-
- it("VITE_HTTP_URL wins over derivation", () => {
- const result = resolveHttpUrl(
- { VITE_HTTP_URL: "https://env.example.com:8888" },
- { protocol: "http:", hostname: "page.example.com" },
- );
- expect(result).toBe("https://env.example.com:8888");
- });
-
- it("derives http://<hostname>:24203 from http location", () => {
- const result = resolveHttpUrl({}, { protocol: "http:", hostname: "100.126.75.103" });
- expect(result).toBe("http://100.126.75.103:24203");
- });
-
- it("derives https://<hostname>:24203 from https location", () => {
- const result = resolveHttpUrl({}, { protocol: "https:", hostname: "arch-razer" });
- expect(result).toBe("https://arch-razer:24203");
- });
-
- it("uses VITE_HTTP_PORT when set", () => {
- const result = resolveHttpUrl(
- { VITE_HTTP_PORT: "3000" },
- { protocol: "http:", hostname: "localhost" },
- );
- expect(result).toBe("http://localhost:3000");
- });
-
- it("falls back to http://localhost:24203 when location is missing", () => {
- const result = resolveHttpUrl({});
- expect(result).toBe("http://localhost:24203");
- });
-
- it("VITE_HTTP_URL empty string treated as unset", () => {
- const result = resolveHttpUrl({ VITE_HTTP_URL: "" }, { protocol: "http:", hostname: "myhost" });
- expect(result).toBe("http://myhost:24203");
- });
-
- it("VITE_HTTP_PORT empty string falls back to default", () => {
- const result = resolveHttpUrl(
- { VITE_HTTP_PORT: "" },
- { protocol: "http:", hostname: "localhost" },
- );
- expect(result).toBe("http://localhost:24203");
- });
+ it("explicit url wins over everything", () => {
+ const result = resolveHttpUrl(
+ { VITE_HTTP_URL: "https://env.example.com:9999" },
+ { protocol: "https:", hostname: "page.example.com" },
+ );
+ expect(result).toBe("https://env.example.com:9999");
+ });
+
+ it("VITE_HTTP_URL wins over derivation", () => {
+ const result = resolveHttpUrl(
+ { VITE_HTTP_URL: "https://env.example.com:8888" },
+ { protocol: "http:", hostname: "page.example.com" },
+ );
+ expect(result).toBe("https://env.example.com:8888");
+ });
+
+ it("derives http://<hostname>:24203 from http location", () => {
+ const result = resolveHttpUrl({}, { protocol: "http:", hostname: "100.126.75.103" });
+ expect(result).toBe("http://100.126.75.103:24203");
+ });
+
+ it("derives https://<hostname>:24203 from https location", () => {
+ const result = resolveHttpUrl({}, { protocol: "https:", hostname: "arch-razer" });
+ expect(result).toBe("https://arch-razer:24203");
+ });
+
+ it("uses VITE_HTTP_PORT when set", () => {
+ const result = resolveHttpUrl(
+ { VITE_HTTP_PORT: "3000" },
+ { protocol: "http:", hostname: "localhost" },
+ );
+ expect(result).toBe("http://localhost:3000");
+ });
+
+ it("falls back to http://localhost:24203 when location is missing", () => {
+ const result = resolveHttpUrl({});
+ expect(result).toBe("http://localhost:24203");
+ });
+
+ it("VITE_HTTP_URL empty string treated as unset", () => {
+ const result = resolveHttpUrl({ VITE_HTTP_URL: "" }, { protocol: "http:", hostname: "myhost" });
+ expect(result).toBe("http://myhost:24203");
+ });
+
+ it("VITE_HTTP_PORT empty string falls back to default", () => {
+ const result = resolveHttpUrl(
+ { VITE_HTTP_PORT: "" },
+ { protocol: "http:", hostname: "localhost" },
+ );
+ expect(result).toBe("http://localhost:24203");
+ });
});
diff --git a/src/app/resolve-http-url.ts b/src/app/resolve-http-url.ts
index 357d2fc..1e20eb2 100644
--- a/src/app/resolve-http-url.ts
+++ b/src/app/resolve-http-url.ts
@@ -1,28 +1,28 @@
export interface HttpUrlEnv {
- readonly VITE_HTTP_URL?: string;
- readonly VITE_HTTP_PORT?: string;
+ readonly VITE_HTTP_URL?: string;
+ readonly VITE_HTTP_PORT?: string;
}
export interface HttpUrlLocation {
- readonly protocol: string;
- readonly hostname: string;
+ readonly protocol: string;
+ readonly hostname: string;
}
const DEFAULT_PORT = "24203";
const DEFAULT_FALLBACK = "http://localhost:24203";
export function resolveHttpUrl(env: HttpUrlEnv, location?: HttpUrlLocation): string {
- if (env.VITE_HTTP_URL !== undefined && env.VITE_HTTP_URL !== "") {
- return env.VITE_HTTP_URL;
- }
+ if (env.VITE_HTTP_URL !== undefined && env.VITE_HTTP_URL !== "") {
+ return env.VITE_HTTP_URL;
+ }
- if (location === undefined) {
- return DEFAULT_FALLBACK;
- }
+ if (location === undefined) {
+ return DEFAULT_FALLBACK;
+ }
- const port =
- env.VITE_HTTP_PORT !== undefined && env.VITE_HTTP_PORT !== ""
- ? env.VITE_HTTP_PORT
- : DEFAULT_PORT;
- return `${location.protocol}//${location.hostname}:${port}`;
+ const port =
+ env.VITE_HTTP_PORT !== undefined && env.VITE_HTTP_PORT !== ""
+ ? env.VITE_HTTP_PORT
+ : DEFAULT_PORT;
+ return `${location.protocol}//${location.hostname}:${port}`;
}
diff --git a/src/app/resolve-ws-url.test.ts b/src/app/resolve-ws-url.test.ts
index 24c2f24..b5ba6c3 100644
--- a/src/app/resolve-ws-url.test.ts
+++ b/src/app/resolve-ws-url.test.ts
@@ -2,52 +2,52 @@ import { describe, expect, it } from "vitest";
import { resolveWsUrl } from "./resolve-ws-url";
describe("resolveWsUrl", () => {
- it("explicit url wins over everything", () => {
- const result = resolveWsUrl(
- { VITE_WS_URL: "wss://env.example.com:9999" },
- { protocol: "https:", hostname: "page.example.com" },
- );
- expect(result).toBe("wss://env.example.com:9999");
- });
-
- it("VITE_WS_URL wins over derivation", () => {
- const result = resolveWsUrl(
- { VITE_WS_URL: "wss://env.example.com:9999" },
- { protocol: "https:", hostname: "page.example.com" },
- );
- expect(result).toBe("wss://env.example.com:9999");
- });
-
- it("derives ws://<hostname>:24205 from http location", () => {
- const result = resolveWsUrl({}, { protocol: "http:", hostname: "100.126.75.103" });
- expect(result).toBe("ws://100.126.75.103:24205");
- });
-
- it("derives wss://<hostname>:24205 from https location", () => {
- const result = resolveWsUrl({}, { protocol: "https:", hostname: "arch-razer" });
- expect(result).toBe("wss://arch-razer:24205");
- });
-
- it("uses VITE_WS_PORT when set", () => {
- const result = resolveWsUrl(
- { VITE_WS_PORT: "3000" },
- { protocol: "http:", hostname: "localhost" },
- );
- expect(result).toBe("ws://localhost:3000");
- });
-
- it("falls back to ws://localhost:24205 when location is missing", () => {
- const result = resolveWsUrl({});
- expect(result).toBe("ws://localhost:24205");
- });
-
- it("VITE_WS_URL empty string treated as unset", () => {
- const result = resolveWsUrl({ VITE_WS_URL: "" }, { protocol: "http:", hostname: "myhost" });
- expect(result).toBe("ws://myhost:24205");
- });
-
- it("VITE_WS_PORT empty string falls back to default", () => {
- const result = resolveWsUrl({ VITE_WS_PORT: "" }, { protocol: "http:", hostname: "localhost" });
- expect(result).toBe("ws://localhost:24205");
- });
+ it("explicit url wins over everything", () => {
+ const result = resolveWsUrl(
+ { VITE_WS_URL: "wss://env.example.com:9999" },
+ { protocol: "https:", hostname: "page.example.com" },
+ );
+ expect(result).toBe("wss://env.example.com:9999");
+ });
+
+ it("VITE_WS_URL wins over derivation", () => {
+ const result = resolveWsUrl(
+ { VITE_WS_URL: "wss://env.example.com:9999" },
+ { protocol: "https:", hostname: "page.example.com" },
+ );
+ expect(result).toBe("wss://env.example.com:9999");
+ });
+
+ it("derives ws://<hostname>:24205 from http location", () => {
+ const result = resolveWsUrl({}, { protocol: "http:", hostname: "100.126.75.103" });
+ expect(result).toBe("ws://100.126.75.103:24205");
+ });
+
+ it("derives wss://<hostname>:24205 from https location", () => {
+ const result = resolveWsUrl({}, { protocol: "https:", hostname: "arch-razer" });
+ expect(result).toBe("wss://arch-razer:24205");
+ });
+
+ it("uses VITE_WS_PORT when set", () => {
+ const result = resolveWsUrl(
+ { VITE_WS_PORT: "3000" },
+ { protocol: "http:", hostname: "localhost" },
+ );
+ expect(result).toBe("ws://localhost:3000");
+ });
+
+ it("falls back to ws://localhost:24205 when location is missing", () => {
+ const result = resolveWsUrl({});
+ expect(result).toBe("ws://localhost:24205");
+ });
+
+ it("VITE_WS_URL empty string treated as unset", () => {
+ const result = resolveWsUrl({ VITE_WS_URL: "" }, { protocol: "http:", hostname: "myhost" });
+ expect(result).toBe("ws://myhost:24205");
+ });
+
+ it("VITE_WS_PORT empty string falls back to default", () => {
+ const result = resolveWsUrl({ VITE_WS_PORT: "" }, { protocol: "http:", hostname: "localhost" });
+ expect(result).toBe("ws://localhost:24205");
+ });
});
diff --git a/src/app/resolve-ws-url.ts b/src/app/resolve-ws-url.ts
index a264606..1c6e259 100644
--- a/src/app/resolve-ws-url.ts
+++ b/src/app/resolve-ws-url.ts
@@ -1,27 +1,27 @@
export interface WsUrlEnv {
- readonly VITE_WS_URL?: string;
- readonly VITE_WS_PORT?: string;
+ readonly VITE_WS_URL?: string;
+ readonly VITE_WS_PORT?: string;
}
export interface WsUrlLocation {
- readonly protocol: string;
- readonly hostname: string;
+ readonly protocol: string;
+ readonly hostname: string;
}
const DEFAULT_PORT = "24205";
const DEFAULT_FALLBACK = "ws://localhost:24205";
export function resolveWsUrl(env: WsUrlEnv, location?: WsUrlLocation): string {
- if (env.VITE_WS_URL !== undefined && env.VITE_WS_URL !== "") {
- return env.VITE_WS_URL;
- }
+ if (env.VITE_WS_URL !== undefined && env.VITE_WS_URL !== "") {
+ return env.VITE_WS_URL;
+ }
- if (location === undefined) {
- return DEFAULT_FALLBACK;
- }
+ if (location === undefined) {
+ return DEFAULT_FALLBACK;
+ }
- const wsProtocol = location.protocol === "https:" ? "wss" : "ws";
- const port =
- env.VITE_WS_PORT !== undefined && env.VITE_WS_PORT !== "" ? env.VITE_WS_PORT : DEFAULT_PORT;
- return `${wsProtocol}://${location.hostname}:${port}`;
+ const wsProtocol = location.protocol === "https:" ? "wss" : "ws";
+ const port =
+ env.VITE_WS_PORT !== undefined && env.VITE_WS_PORT !== "" ? env.VITE_WS_PORT : DEFAULT_PORT;
+ return `${wsProtocol}://${location.hostname}:${port}`;
}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 0e2d112..78f6ede 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -1,38 +1,38 @@
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- CompactPercentResponse,
- CompactResponse,
- ComputerListResponse,
- ComputerStatusResponse,
- ConversationCompactedMessage,
- ConversationComputerResponse,
- ConversationHistoryResponse,
- ConversationListResponse,
- ConversationMetricsResponse,
- ConversationOpenMessage,
- ConversationStatusChangedMessage,
- CwdResponse,
- LspStatusResponse,
- McpStatusResponse,
- ModelMetadata,
- ModelResponse,
- ModelsResponse,
- ReasoningEffort,
- ReasoningEffortResponse,
- SetCompactPercentRequest,
- SetConversationComputerRequest,
- SetCwdRequest,
- SetModelRequest,
- SetReasoningEffortRequest,
- SetSystemPromptTemplateRequest,
- SetTitleRequest,
- SystemPromptTemplateResponse,
- SystemPromptVariable,
- SystemPromptVariablesResponse,
- TestComputerResponse,
- WarmRequest,
- WarmResponse,
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ CompactPercentResponse,
+ CompactResponse,
+ ComputerListResponse,
+ ComputerStatusResponse,
+ ConversationCompactedMessage,
+ ConversationComputerResponse,
+ ConversationHistoryResponse,
+ ConversationListResponse,
+ ConversationMetricsResponse,
+ ConversationOpenMessage,
+ ConversationStatusChangedMessage,
+ CwdResponse,
+ LspStatusResponse,
+ McpStatusResponse,
+ ModelMetadata,
+ ModelResponse,
+ ModelsResponse,
+ ReasoningEffort,
+ ReasoningEffortResponse,
+ SetCompactPercentRequest,
+ SetConversationComputerRequest,
+ SetCwdRequest,
+ SetModelRequest,
+ SetReasoningEffortRequest,
+ SetSystemPromptTemplateRequest,
+ SetTitleRequest,
+ SystemPromptTemplateResponse,
+ SystemPromptVariable,
+ SystemPromptVariablesResponse,
+ TestComputerResponse,
+ WarmRequest,
+ WarmResponse,
} from "@dispatch/transport-contract";
import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
import type { ComputerEntry, ConversationStatus } from "@dispatch/wire";
@@ -43,26 +43,26 @@ import type { WebSocketLike } from "../adapters/ws";
import { createSurfaceSocket, type SurfaceSocketOptions } from "../adapters/ws";
import { normalizeChatLimit } from "../core/chunks";
import {
- applyServerMessage,
- getSurfaceSpec,
- type ProtocolState,
- initialState as protocolInitialState,
- invoke as protocolInvoke,
- subscribe as protocolSubscribe,
- unsubscribe as protocolUnsubscribe,
+ applyServerMessage,
+ getSurfaceSpec,
+ type ProtocolState,
+ initialState as protocolInitialState,
+ invoke as protocolInvoke,
+ subscribe as protocolSubscribe,
+ unsubscribe as protocolUnsubscribe,
} from "../core/protocol";
import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
import { createChatStore } from "../features/chat";
import type { ConversationCache } from "../features/conversation-cache";
import { createConversationCache } from "../features/conversation-cache";
import type {
- HeartbeatConfig,
- HeartbeatConfigPatch,
- HeartbeatConfigResult,
- HeartbeatNextRunResult,
- HeartbeatRun,
- HeartbeatRunsResult,
- HeartbeatStopResult,
+ HeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ HeartbeatNextRunResult,
+ HeartbeatRun,
+ HeartbeatRunsResult,
+ HeartbeatStopResult,
} from "../features/heartbeat";
import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat";
import type { Tab, TabsState } from "../features/tabs";
@@ -75,1715 +75,1715 @@ const DEFAULT_MODEL = "opencode/deepseek-v4-flash";
/** Outcome of a manual `POST /chat/warm` (the "warm now" affordance). */
export type WarmResult =
- | { readonly ok: true; readonly response: WarmResponse }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly response: WarmResponse }
+ | { readonly ok: false; readonly error: string };
/** Outcome of `PUT /conversations/:id/cwd`. */
export type CwdResult =
- | { readonly ok: true; readonly cwd: string | null }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly cwd: string | null }
+ | { readonly ok: false; readonly error: string };
/** Outcome of `PUT /conversations/:id/computer` (set/clear the per-conversation computer). */
export type ComputerResult =
- | { readonly ok: true; readonly computerId: string | null }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly computerId: string | null }
+ | { readonly ok: false; readonly error: string };
/** Outcome of `GET /computers/:alias/status` (the live connection state). */
export type ComputerStatusResult =
- | { readonly ok: true; readonly response: ComputerStatusResponse }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly response: ComputerStatusResponse }
+ | { readonly ok: false; readonly error: string };
/** Outcome of `POST /computers/:alias/test` (one-shot connectivity probe). */
export type TestComputerResult =
- | { readonly ok: true; readonly response: TestComputerResponse }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly response: TestComputerResponse }
+ | { readonly ok: false; readonly error: string };
/** Outcome of `GET /conversations/:id/lsp`. */
export type LspResult =
- | { readonly ok: true; readonly response: LspStatusResponse }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly response: LspStatusResponse }
+ | { readonly ok: false; readonly error: string };
/** Outcome of `GET /conversations/:id/mcp`. */
export type McpResult =
- | { readonly ok: true; readonly response: McpStatusResponse }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly response: McpStatusResponse }
+ | { readonly ok: false; readonly error: string };
/** Outcome of `PUT /conversations/:id/reasoning-effort`. */
export type ReasoningEffortResult =
- | { readonly ok: true; readonly reasoningEffort: ReasoningEffort }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly reasoningEffort: ReasoningEffort }
+ | { readonly ok: false; readonly error: string };
/** Outcome of `POST /conversations/:id/compact` (manual compaction). */
export type CompactResult =
- | { readonly ok: true; readonly response: CompactResponse }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly response: CompactResponse }
+ | { readonly ok: false; readonly error: string };
/** Outcome of `PUT /conversations/:id/compact-percent`. */
export type CompactPercentResult =
- | { readonly ok: true; readonly percent: number }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly percent: number }
+ | { readonly ok: false; readonly error: string };
/** Outcome of `GET /system-prompt` (global template load). */
export type SystemPromptLoadResult =
- | { readonly ok: true; readonly template: string }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly template: string }
+ | { readonly ok: false; readonly error: string };
/** Outcome of `PUT /system-prompt` (global template save). */
export type SystemPromptSaveResult = SystemPromptLoadResult;
/** Outcome of `GET /system-prompt/variables` (variable catalog). */
export type SystemPromptVariablesResult =
- | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] }
+ | { readonly ok: false; readonly error: string };
/** Outcome of persisting a chat-limit setting (localStorage; FE-local). */
export type ChatLimitResult =
- | { readonly ok: true; readonly chatLimit: number }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly chatLimit: number }
+ | { readonly ok: false; readonly error: string };
export interface AppStore {
- readonly tabs: readonly Tab[];
- readonly activeConversationId: string | null;
- /** The workspace currently in view (URL slug); tabs are filtered to it. */
- readonly activeWorkspaceId: string;
- readonly activeChat: ChatStore;
- readonly models: readonly string[];
- /** Per-model metadata (contextWindow, etc.) from `GET /models`. */
- readonly modelInfo: Readonly<Record<string, ModelMetadata>>;
- readonly activeModel: string;
- readonly catalog: ProtocolState["catalog"];
- /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */
- readonly surfaces: readonly SurfaceSpec[];
- readonly lastError: ProtocolState["lastError"];
- /** The localStorage instance the store uses for persistence (tabs, chatLimit).
- * Exposed so the shell can persist sidebar layout via the same adapter. */
- readonly storage: Storage | undefined;
- /** The current spec for one surface by id (discovery-by-id), or null if absent. */
- surface(surfaceId: string): SurfaceSpec | null;
- send(text: string): void;
- /**
- * Enqueue a steering message onto the focused conversation's queue
- * (`chat.queue` WS op). While a turn is generating, the message is delivered
- * mid-turn at the next tool-result boundary; when idle, the server
- * auto-starts a turn (equivalent to `send`). Safe to offer whenever the user
- * wants to add input — the server owns the idle-vs-generating decision.
- */
- queueMessage(text: string): void;
- selectModel(model: string): void;
- newDraft(): void;
- /** Switch the active workspace (on route change) + reset to a fresh draft in it. */
- setActiveWorkspace(workspaceId: string): void;
- selectTab(conversationId: string): void;
- closeTab(conversationId: string): void;
- renameTab(conversationId: string, title: string): void;
- invoke(surfaceId: string, actionId: string, payload?: unknown): void;
- /**
- * Manually warm the focused conversation's prompt cache (`POST /chat/warm`).
- * Returns null when no conversation is focused (a draft has nothing to warm).
- */
- warmNow(): Promise<WarmResult | null>;
- /** The workspace conversation's persisted working directory, or null when unset. */
- readonly cwd: string | null;
- /** The conversation workspace settings target: the active tab, or the pending draft's id. */
- readonly currentConversationId: string;
- /**
- * Set the workspace conversation's working directory (`PUT /conversations/:id/cwd`).
- * Works for a draft too (its id survives promotion), so the first turn runs in it.
- */
- setCwd(cwd: string): Promise<CwdResult | null>;
- /**
- * The workspace conversation's persisted computer (an SSH `Host` alias), or
- * null when never set / local. Seeded from the backend on focus change.
- */
- readonly computerId: string | null;
- /**
- * Persist the workspace conversation's computer (`PUT /conversations/:id/computer`).
- * Pass null to clear → the conversation inherits the workspace default → local.
- * Works for a draft too (its id survives promotion). Not seen by the agent — a
- * user-facing tool-execution target only.
- */
- setComputer(computerId: string | null): Promise<ComputerResult | null>;
- /**
- * Every remote computer discovered from the user's `~/.ssh/config`
- * (`GET /computers`), fetched on boot. Read-only — there is no Computer CRUD
- * (the user edits their ssh config to add one). Empty until the `ssh`
- * extension lands.
- */
- readonly computers: readonly ComputerEntry[];
- /**
- * The live connection state of a computer (`GET /computers/:alias/status`):
- * whether Dispatch currently holds an open SSH session to it. Returns null
- * only if no alias is given (the focused conversation is local). Polled by the
- * `ComputerField` while a computer is selected.
- */
- computerStatus(alias: string): Promise<ComputerStatusResult | null>;
- /**
- * One-shot connectivity probe (`POST /computers/:alias/test`): Dispatch opens
- * an SSH connection to the alias, runs a trivial command, then closes. `ok` is
- * true on success; `error` carries the failure reason otherwise.
- */
- testComputer(alias: string): Promise<TestComputerResult | null>;
- /**
- * The workspace conversation's persisted reasoning effort, or null when never
- * set (the server then resolves turns at the default, `"high"`).
- */
- readonly reasoningEffort: ReasoningEffort | null;
- /**
- * Persist the workspace conversation's reasoning effort
- * (`PUT /conversations/:id/reasoning-effort`). Works for a draft too (its id
- * survives promotion), so the first turn already runs at the chosen level.
- * Takes effect from the NEXT turn; resolution stays server-owned.
- */
- setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null>;
- /**
- * Manually trigger conversation compaction (`POST /conversations/:id/compact`).
- * Summarizes old messages + retains the most recent N. Returns null when no
- * conversation is focused (a draft has nothing to compact).
- */
- compactNow(keepLastN?: number): Promise<CompactResult | null>;
- /**
- * Stop an in-flight generation (`POST /conversations/:id/stop`). Aborts the
- * turn without closing the conversation — partial messages are persisted, the
- * turn seals with `reason: "aborted"`, and the conversation goes `active → idle`.
- * Returns null when no conversation is focused.
- */
- stopGeneration(): void;
- /**
- * The workspace conversation's auto-compact percent (0-100). `0` = disabled
- * (manual only); a positive number = auto-compact triggers when the last
- * turn's input tokens exceed it. Seeded from the backend on focus change.
- */
- readonly compactPercent: number | null;
- /**
- * Persist the workspace conversation's auto-compact percent
- * (`PUT /conversations/:id/compact-percent`). `0` disables; 1-100 sets the
- * trigger percentage of the model's context window. Default (null) is 85.
- * number enables. Works for a draft too (its id survives promotion).
- */
- setCompactPercent(percent: number): Promise<CompactPercentResult | null>;
- /**
- * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`).
- * The backend lazily spawns servers, so this may take a moment on the first call for a cwd.
- */
- lspStatus(): Promise<LspResult | null>;
- /**
- * Fetch the workspace conversation's MCP server status (`GET /conversations/:id/mcp`).
- * Mirrors the LSP status endpoint: returns `{cwd, servers}` with empty `servers`
- * when no cwd is set; the backend lazily connects servers, so this may take a
- * moment on the first call for a cwd.
- */
- mcpStatus(): Promise<McpResult | null>;
- /**
- * Load the global system prompt template (`GET /system-prompt`). The template is
- * conversation-agnostic; it is resolved once per conversation on first turn and
- * persisted for prompt-cache safety.
- */
- loadSystemPrompt(): Promise<SystemPromptLoadResult>;
- /**
- * Persist the global system prompt template (`PUT /system-prompt`). Changes apply
- * to new conversations on their first turn; existing conversations keep their
- * resolved system prompt until compaction.
- */
- setSystemPrompt(template: string): Promise<SystemPromptSaveResult>;
- /**
- * Load the static catalog of available system prompt variables (`GET /system-prompt/variables`).
- * Used by the builder to render the variable selector buttons.
- */
- loadSystemPromptVariables(): Promise<SystemPromptVariablesResult>;
- /** The persisted chat limit (max loaded chunks per conversation). */
- readonly chatLimit: number;
- /**
- * A conversation's backend lifecycle status (`active`/`idle`/`closed`), or
- * `undefined` when unknown. Drives the tab-bar generating indicator
- * (cross-device: a tab spinning because another device's turn is running).
- */
- conversationStatus(conversationId: string): ConversationStatus | undefined;
- /**
- * Persist + live-apply a new chat limit: writes `dispatch.chatLimit` to
- * localStorage and propagates to every live chat store (trim if lower,
- * deferred via the unload gate while a reader is scrolled up; no-op if
- * higher — page unloaded history back in via "Show earlier"). Stores created
- * afterwards pick the new limit up at creation. Always succeeds (FE-local).
- */
- setChatLimit(limit: number): Promise<ChatLimitResult>;
- /**
- * Wire the chat-limit unload gate (composition-root injection, called once by
- * the shell after it owns the scroll region): unloading old chunks is allowed
- * only while the gate returns true — i.e. the reader is stuck to the bottom —
- * so a trim never yanks content out from under someone reading history.
- * Before attachment unloading is allowed (the initial view starts at the
- * bottom).
- */
- attachUnloadGate(gate: () => boolean): void;
- /**
- * Load the active workspace's heartbeat config
- * (`GET /workspaces/:id/heartbeat`). Workspace-scoped (NOT per-conversation):
- * the backend runs an autonomous agent loop on a configured interval, writing
- * each run into a dedicated conversation. The config covers the system/task
- * prompts, model, reasoning effort, interval, and an enabled flag.
- */
- heartbeatConfig(): Promise<HeartbeatConfigResult>;
- /**
- * Persist a partial heartbeat config patch
- * (`PUT /workspaces/:id/heartbeat`). The backend merges the patch onto the
- * stored config; returns the full updated config.
- */
- setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult>;
- /**
- * Load the active workspace's heartbeat run history
- * (`GET /workspaces/:id/heartbeat/runs`). Each run references the conversation
- * it wrote to — open one via {@link watchConversation} to see its chat live.
- */
- heartbeatRuns(): Promise<HeartbeatRunsResult>;
- /**
- * Stop a running heartbeat run (`POST /workspaces/:id/heartbeat/runs/:runId/stop`).
- * The run's in-flight turn seals (its conversation keeps streaming until it
- * ends); the run's status flips to `stopped` (visible on the next runs poll).
- */
- stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult>;
- /**
- * Fetch the server-authoritative next-run timestamp
- * (`GET /workspaces/:id/heartbeat/next-run`) — when the next heartbeat run
- * will fire (ISO 8601), or null when disabled / no run scheduled. The FE shows
- * a live countdown from this. When the endpoint is absent (404 — backend
- * hasn't shipped CR-HB-3 yet) it returns `ok: false` so the FE falls back to
- * an approximation from the runs + config.
- */
- heartbeatNextRun(): Promise<HeartbeatNextRunResult>;
- /**
- * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
- * modal): ensures a live {@link ChatStore} for the conversation, subscribing
- * to its turn stream (`chat.subscribe`) + loading history. Reuses the open
- * tab's store if the conversation is already a tab; otherwise creates an
- * EPHEMERAL watch store (separate from tabs — never opens a tab). Deltas are
- * routed to it automatically. Pair every open with {@link unwatchConversation}
- * on close to unsubscribe + dispose the ephemeral store.
- */
- watchConversation(conversationId: string): ChatStore;
- /** Dispose + unsubscribe a watch opened by {@link watchConversation}. */
- unwatchConversation(conversationId: string): void;
- /**
- * A critical error that blocks normal operation (e.g. the cross-device tab
- * restore fetch failed). When non-null, a full-screen modal is shown with the
- * error details. Cleared by `clearFatalError` (the modal's dismiss button).
- */
- readonly fatalError: string | null;
- /** Dismiss the fatal error (called by the error modal's X button). */
- clearFatalError(): void;
- dispose(): void;
+ readonly tabs: readonly Tab[];
+ readonly activeConversationId: string | null;
+ /** The workspace currently in view (URL slug); tabs are filtered to it. */
+ readonly activeWorkspaceId: string;
+ readonly activeChat: ChatStore;
+ readonly models: readonly string[];
+ /** Per-model metadata (contextWindow, etc.) from `GET /models`. */
+ readonly modelInfo: Readonly<Record<string, ModelMetadata>>;
+ readonly activeModel: string;
+ readonly catalog: ProtocolState["catalog"];
+ /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */
+ readonly surfaces: readonly SurfaceSpec[];
+ readonly lastError: ProtocolState["lastError"];
+ /** The localStorage instance the store uses for persistence (tabs, chatLimit).
+ * Exposed so the shell can persist sidebar layout via the same adapter. */
+ readonly storage: Storage | undefined;
+ /** The current spec for one surface by id (discovery-by-id), or null if absent. */
+ surface(surfaceId: string): SurfaceSpec | null;
+ send(text: string): void;
+ /**
+ * Enqueue a steering message onto the focused conversation's queue
+ * (`chat.queue` WS op). While a turn is generating, the message is delivered
+ * mid-turn at the next tool-result boundary; when idle, the server
+ * auto-starts a turn (equivalent to `send`). Safe to offer whenever the user
+ * wants to add input — the server owns the idle-vs-generating decision.
+ */
+ queueMessage(text: string): void;
+ selectModel(model: string): void;
+ newDraft(): void;
+ /** Switch the active workspace (on route change) + reset to a fresh draft in it. */
+ setActiveWorkspace(workspaceId: string): void;
+ selectTab(conversationId: string): void;
+ closeTab(conversationId: string): void;
+ renameTab(conversationId: string, title: string): void;
+ invoke(surfaceId: string, actionId: string, payload?: unknown): void;
+ /**
+ * Manually warm the focused conversation's prompt cache (`POST /chat/warm`).
+ * Returns null when no conversation is focused (a draft has nothing to warm).
+ */
+ warmNow(): Promise<WarmResult | null>;
+ /** The workspace conversation's persisted working directory, or null when unset. */
+ readonly cwd: string | null;
+ /** The conversation workspace settings target: the active tab, or the pending draft's id. */
+ readonly currentConversationId: string;
+ /**
+ * Set the workspace conversation's working directory (`PUT /conversations/:id/cwd`).
+ * Works for a draft too (its id survives promotion), so the first turn runs in it.
+ */
+ setCwd(cwd: string): Promise<CwdResult | null>;
+ /**
+ * The workspace conversation's persisted computer (an SSH `Host` alias), or
+ * null when never set / local. Seeded from the backend on focus change.
+ */
+ readonly computerId: string | null;
+ /**
+ * Persist the workspace conversation's computer (`PUT /conversations/:id/computer`).
+ * Pass null to clear → the conversation inherits the workspace default → local.
+ * Works for a draft too (its id survives promotion). Not seen by the agent — a
+ * user-facing tool-execution target only.
+ */
+ setComputer(computerId: string | null): Promise<ComputerResult | null>;
+ /**
+ * Every remote computer discovered from the user's `~/.ssh/config`
+ * (`GET /computers`), fetched on boot. Read-only — there is no Computer CRUD
+ * (the user edits their ssh config to add one). Empty until the `ssh`
+ * extension lands.
+ */
+ readonly computers: readonly ComputerEntry[];
+ /**
+ * The live connection state of a computer (`GET /computers/:alias/status`):
+ * whether Dispatch currently holds an open SSH session to it. Returns null
+ * only if no alias is given (the focused conversation is local). Polled by the
+ * `ComputerField` while a computer is selected.
+ */
+ computerStatus(alias: string): Promise<ComputerStatusResult | null>;
+ /**
+ * One-shot connectivity probe (`POST /computers/:alias/test`): Dispatch opens
+ * an SSH connection to the alias, runs a trivial command, then closes. `ok` is
+ * true on success; `error` carries the failure reason otherwise.
+ */
+ testComputer(alias: string): Promise<TestComputerResult | null>;
+ /**
+ * The workspace conversation's persisted reasoning effort, or null when never
+ * set (the server then resolves turns at the default, `"high"`).
+ */
+ readonly reasoningEffort: ReasoningEffort | null;
+ /**
+ * Persist the workspace conversation's reasoning effort
+ * (`PUT /conversations/:id/reasoning-effort`). Works for a draft too (its id
+ * survives promotion), so the first turn already runs at the chosen level.
+ * Takes effect from the NEXT turn; resolution stays server-owned.
+ */
+ setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null>;
+ /**
+ * Manually trigger conversation compaction (`POST /conversations/:id/compact`).
+ * Summarizes old messages + retains the most recent N. Returns null when no
+ * conversation is focused (a draft has nothing to compact).
+ */
+ compactNow(keepLastN?: number): Promise<CompactResult | null>;
+ /**
+ * Stop an in-flight generation (`POST /conversations/:id/stop`). Aborts the
+ * turn without closing the conversation — partial messages are persisted, the
+ * turn seals with `reason: "aborted"`, and the conversation goes `active → idle`.
+ * Returns null when no conversation is focused.
+ */
+ stopGeneration(): void;
+ /**
+ * The workspace conversation's auto-compact percent (0-100). `0` = disabled
+ * (manual only); a positive number = auto-compact triggers when the last
+ * turn's input tokens exceed it. Seeded from the backend on focus change.
+ */
+ readonly compactPercent: number | null;
+ /**
+ * Persist the workspace conversation's auto-compact percent
+ * (`PUT /conversations/:id/compact-percent`). `0` disables; 1-100 sets the
+ * trigger percentage of the model's context window. Default (null) is 85.
+ * number enables. Works for a draft too (its id survives promotion).
+ */
+ setCompactPercent(percent: number): Promise<CompactPercentResult | null>;
+ /**
+ * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`).
+ * The backend lazily spawns servers, so this may take a moment on the first call for a cwd.
+ */
+ lspStatus(): Promise<LspResult | null>;
+ /**
+ * Fetch the workspace conversation's MCP server status (`GET /conversations/:id/mcp`).
+ * Mirrors the LSP status endpoint: returns `{cwd, servers}` with empty `servers`
+ * when no cwd is set; the backend lazily connects servers, so this may take a
+ * moment on the first call for a cwd.
+ */
+ mcpStatus(): Promise<McpResult | null>;
+ /**
+ * Load the global system prompt template (`GET /system-prompt`). The template is
+ * conversation-agnostic; it is resolved once per conversation on first turn and
+ * persisted for prompt-cache safety.
+ */
+ loadSystemPrompt(): Promise<SystemPromptLoadResult>;
+ /**
+ * Persist the global system prompt template (`PUT /system-prompt`). Changes apply
+ * to new conversations on their first turn; existing conversations keep their
+ * resolved system prompt until compaction.
+ */
+ setSystemPrompt(template: string): Promise<SystemPromptSaveResult>;
+ /**
+ * Load the static catalog of available system prompt variables (`GET /system-prompt/variables`).
+ * Used by the builder to render the variable selector buttons.
+ */
+ loadSystemPromptVariables(): Promise<SystemPromptVariablesResult>;
+ /** The persisted chat limit (max loaded chunks per conversation). */
+ readonly chatLimit: number;
+ /**
+ * A conversation's backend lifecycle status (`active`/`idle`/`closed`), or
+ * `undefined` when unknown. Drives the tab-bar generating indicator
+ * (cross-device: a tab spinning because another device's turn is running).
+ */
+ conversationStatus(conversationId: string): ConversationStatus | undefined;
+ /**
+ * Persist + live-apply a new chat limit: writes `dispatch.chatLimit` to
+ * localStorage and propagates to every live chat store (trim if lower,
+ * deferred via the unload gate while a reader is scrolled up; no-op if
+ * higher — page unloaded history back in via "Show earlier"). Stores created
+ * afterwards pick the new limit up at creation. Always succeeds (FE-local).
+ */
+ setChatLimit(limit: number): Promise<ChatLimitResult>;
+ /**
+ * Wire the chat-limit unload gate (composition-root injection, called once by
+ * the shell after it owns the scroll region): unloading old chunks is allowed
+ * only while the gate returns true — i.e. the reader is stuck to the bottom —
+ * so a trim never yanks content out from under someone reading history.
+ * Before attachment unloading is allowed (the initial view starts at the
+ * bottom).
+ */
+ attachUnloadGate(gate: () => boolean): void;
+ /**
+ * Load the active workspace's heartbeat config
+ * (`GET /workspaces/:id/heartbeat`). Workspace-scoped (NOT per-conversation):
+ * the backend runs an autonomous agent loop on a configured interval, writing
+ * each run into a dedicated conversation. The config covers the system/task
+ * prompts, model, reasoning effort, interval, and an enabled flag.
+ */
+ heartbeatConfig(): Promise<HeartbeatConfigResult>;
+ /**
+ * Persist a partial heartbeat config patch
+ * (`PUT /workspaces/:id/heartbeat`). The backend merges the patch onto the
+ * stored config; returns the full updated config.
+ */
+ setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult>;
+ /**
+ * Load the active workspace's heartbeat run history
+ * (`GET /workspaces/:id/heartbeat/runs`). Each run references the conversation
+ * it wrote to — open one via {@link watchConversation} to see its chat live.
+ */
+ heartbeatRuns(): Promise<HeartbeatRunsResult>;
+ /**
+ * Stop a running heartbeat run (`POST /workspaces/:id/heartbeat/runs/:runId/stop`).
+ * The run's in-flight turn seals (its conversation keeps streaming until it
+ * ends); the run's status flips to `stopped` (visible on the next runs poll).
+ */
+ stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult>;
+ /**
+ * Fetch the server-authoritative next-run timestamp
+ * (`GET /workspaces/:id/heartbeat/next-run`) — when the next heartbeat run
+ * will fire (ISO 8601), or null when disabled / no run scheduled. The FE shows
+ * a live countdown from this. When the endpoint is absent (404 — backend
+ * hasn't shipped CR-HB-3 yet) it returns `ok: false` so the FE falls back to
+ * an approximation from the runs + config.
+ */
+ heartbeatNextRun(): Promise<HeartbeatNextRunResult>;
+ /**
+ * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
+ * modal): ensures a live {@link ChatStore} for the conversation, subscribing
+ * to its turn stream (`chat.subscribe`) + loading history. Reuses the open
+ * tab's store if the conversation is already a tab; otherwise creates an
+ * EPHEMERAL watch store (separate from tabs — never opens a tab). Deltas are
+ * routed to it automatically. Pair every open with {@link unwatchConversation}
+ * on close to unsubscribe + dispose the ephemeral store.
+ */
+ watchConversation(conversationId: string): ChatStore;
+ /** Dispose + unsubscribe a watch opened by {@link watchConversation}. */
+ unwatchConversation(conversationId: string): void;
+ /**
+ * A critical error that blocks normal operation (e.g. the cross-device tab
+ * restore fetch failed). When non-null, a full-screen modal is shown with the
+ * error details. Cleared by `clearFatalError` (the modal's dismiss button).
+ */
+ readonly fatalError: string | null;
+ /** Dismiss the fatal error (called by the error modal's X button). */
+ clearFatalError(): void;
+ dispose(): void;
}
export interface CreateAppStoreOptions {
- url?: string;
- httpUrl?: string;
- socketFactory?: (url: string) => WebSocketLike;
- fetchImpl?: typeof fetch;
- indexedDB?: IDBFactory;
- conversationId?: string;
- localStorage?: Storage;
- /** The workspace to scope to at boot (its URL slug); "default" if absent. */
- workspaceId?: string;
+ url?: string;
+ httpUrl?: string;
+ socketFactory?: (url: string) => WebSocketLike;
+ fetchImpl?: typeof fetch;
+ indexedDB?: IDBFactory;
+ conversationId?: string;
+ localStorage?: Storage;
+ /** The workspace to scope to at boot (its URL slug); "default" if absent. */
+ workspaceId?: string;
}
function createHistorySync(httpBase: string, fetchImpl: typeof fetch): HistorySync {
- return async (conversationId, sinceSeq, window) => {
- let url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}?sinceSeq=${sinceSeq}`;
- // CR-5 windowing ([email protected]): both must be positive
- // integers when present (the server 400s otherwise; callers guarantee it).
- if (window?.limit !== undefined) url += `&limit=${window.limit}`;
- if (window?.beforeSeq !== undefined) url += `&beforeSeq=${window.beforeSeq}`;
- const res = await fetchImpl(url);
- if (!res.ok) {
- throw new Error(`History sync failed: ${res.status}`);
- }
- return (await res.json()) as ConversationHistoryResponse;
- };
+ return async (conversationId, sinceSeq, window) => {
+ let url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}?sinceSeq=${sinceSeq}`;
+ // CR-5 windowing ([email protected]): both must be positive
+ // integers when present (the server 400s otherwise; callers guarantee it).
+ if (window?.limit !== undefined) url += `&limit=${window.limit}`;
+ if (window?.beforeSeq !== undefined) url += `&beforeSeq=${window.beforeSeq}`;
+ const res = await fetchImpl(url);
+ if (!res.ok) {
+ throw new Error(`History sync failed: ${res.status}`);
+ }
+ return (await res.json()) as ConversationHistoryResponse;
+ };
}
function createMetricsSync(httpBase: string, fetchImpl: typeof fetch): MetricsSync {
- return async (conversationId: string) => {
- const url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}/metrics`;
- const res = await fetchImpl(url);
- if (!res.ok) return { turns: [] };
- return (await res.json()) as ConversationMetricsResponse;
- };
+ return async (conversationId: string) => {
+ const url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}/metrics`;
+ const res = await fetchImpl(url);
+ if (!res.ok) return { turns: [] };
+ return (await res.json()) as ConversationMetricsResponse;
+ };
}
export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
- let protocol = $state<ProtocolState>(protocolInitialState());
- let models = $state<readonly string[]>([]);
- let modelInfo = $state<Readonly<Record<string, ModelMetadata>>>({});
- // Discovered SSH computers (`GET /computers`). Global (like `models`); empty
- // until the `ssh` extension lands. Read-only — no CRUD (the user edits their
- // `~/.ssh/config`).
- let computers = $state<readonly ComputerEntry[]>([]);
- let activeModel = $state(DEFAULT_MODEL);
- let fatalError = $state<string | null>(null);
-
- // The workspace currently in view (its URL slug); "default" until routing
- // sets it. Tabs are filtered to this workspace; a new conversation is stamped
- // with it on `chat.send`.
- let activeWorkspaceId = $state<string>(opts?.workspaceId ?? "default");
-
- const wsLocation = typeof location !== "undefined" ? location : undefined;
- const wsUrl =
- opts?.url ??
- resolveWsUrl(
- { VITE_WS_URL: import.meta.env.VITE_WS_URL, VITE_WS_PORT: import.meta.env.VITE_WS_PORT },
- wsLocation,
- );
-
- const httpLocation = typeof location !== "undefined" ? location : undefined;
- const httpBase =
- opts?.httpUrl ??
- resolveHttpUrl(
- {
- VITE_HTTP_URL: import.meta.env.VITE_HTTP_URL,
- VITE_HTTP_PORT: import.meta.env.VITE_HTTP_PORT,
- },
- httpLocation,
- );
-
- const fetchImpl = opts?.fetchImpl ?? globalThis.fetch.bind(globalThis);
- const indexedDBFactory = opts?.indexedDB ?? globalThis.indexedDB;
- const localStorageOpt = opts?.localStorage ?? globalThis.localStorage;
-
- const storageAdapter = createLocalStore<TabsState>("dispatch.tabs", {
- storage: localStorageOpt,
- });
- const tabsStore: TabsStore = createTabsStore(storageAdapter);
-
- // The chat limit (max loaded chunks per conversation) — a persisted local
- // setting surfaced in the sidebar's Settings view. Reactive so the field +
- // any live-apply re-trim update together. The default is written back on
- // first run so the knob is discoverable in localStorage too.
- const chatLimitStore = createLocalStore<number>("dispatch.chatLimit", {
- storage: localStorageOpt,
- });
- const storedChatLimit = chatLimitStore.load();
- const normalizedChatLimit = normalizeChatLimit(storedChatLimit);
- let chatLimit = $state(normalizedChatLimit);
- if (storedChatLimit === null) {
- chatLimitStore.save(normalizedChatLimit);
- }
-
- // Unload gate — attached by the shell once it owns the scroll region (see
- // `AppStore.attachUnloadGate`). Until then, unloading is allowed.
- let unloadGate: (() => boolean) | null = null;
-
- const cache: ConversationCache = createConversationCache(
- createIdbChunkStore({ indexedDB: indexedDBFactory }),
- );
-
- const historySync = createHistorySync(httpBase, fetchImpl);
- const metricsSync = createMetricsSync(httpBase, fetchImpl);
-
- const chatStores = new Map<string, ChatStore>();
-
- // Ephemeral chat stores for MODAL viewers (the heartbeat run-chat modal): a
- // watch on a conversation's live turn stream WITHOUT opening a tab. Separate
- // from `chatStores` (tabs) so closing a modal never disturbs the tab strip,
- // and a tab's conversation reuses its own store (see `watchConversation`).
- // Deltas are routed here in addition to `chatStores`.
- const watchStores = new Map<string, ChatStore>();
-
- function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore {
- return createChatStore({
- conversationId,
- model,
- workspaceId,
- transport: {
- send(msg) {
- socket?.send(msg);
- },
- },
- historySync,
- metricsSync,
- cache,
- // Read from the persisted store (kept in sync with the reactive `chatLimit`
- // by `setChatLimit` + boot) so this snapshot doesn't reference the `$state`
- // — each store captures its limit at creation; live updates go through
- // `setChatLimit`.
- chatLimit: normalizeChatLimit(chatLimitStore.load()),
- canUnload: () => (unloadGate === null ? true : unloadGate()),
- onError: (context, err) => {
- reportError(`${context} (conversation: ${conversationId})`, err);
- },
- });
- }
-
- const initialDraftId = randomId();
- // Read `activeWorkspaceId` with untrack to suppress Svelte's
- // `state_referenced_locally` warning — this intentionally captures the
- // INITIAL workspace for the boot draft. When the workspace changes later,
- // `setActiveWorkspace` creates a fresh draft store with the new id.
- let draftStore: ChatStore = createChatFor(
- initialDraftId,
- DEFAULT_MODEL,
- untrack(() => activeWorkspaceId),
- );
- let draftConversationId: string = initialDraftId;
-
- let activeChat = $state<ChatStore>(draftStore as ChatStore);
-
- // The active conversation's persisted working directory (per-tab). Seeded from
- // the backend on focus change; null for a draft / when unset.
- let cwd = $state<string | null>(null);
-
- /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */
- async function refreshCwd(): Promise<void> {
- const id = workspaceConversationId();
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`);
- if (!res.ok) return;
- const data = (await res.json()) as CwdResponse;
- // Guard a slow response losing a race with a conversation switch.
- if (workspaceConversationId() === id) cwd = data.cwd ?? null;
- } catch (err) {
- reportError("Failed to load working directory", err);
- }
- }
-
- // The active conversation's persisted computer (SSH Host alias). Seeded on
- // focus change; null = local / never set (inherits the workspace default).
- let computerId = $state<string | null>(null);
-
- /**
- * Refetch the workspace conversation's persisted computer into reactive state
- * (works for a draft too). A draft's id 404s until promoted; `res.ok` is false
- * so it is a silent no-op (mirrors `refreshCwd` for a draft).
- */
- async function refreshComputer(): Promise<void> {
- const id = workspaceConversationId();
- // Clear immediately so a switch never shows the PREVIOUS conversation's
- // computer while the fetch is in flight (null renders as "Local").
- computerId = null;
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/computer`);
- if (!res.ok) return;
- const data = (await res.json()) as ConversationComputerResponse;
- // Guard a slow response losing a race with a conversation switch.
- if (workspaceConversationId() === id) computerId = data.computerId ?? null;
- } catch (err) {
- reportError("Failed to load computer", err);
- }
- }
-
- /** Refetch the workspace conversation's persisted model (works for a draft too). */
- async function refreshModel(): Promise<void> {
- const id = workspaceConversationId();
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/model`);
- if (!res.ok) return;
- const data = (await res.json()) as ModelResponse;
- if (workspaceConversationId() !== id) return;
- if (typeof data.model === "string" && data.model.length > 0) {
- activeModel = data.model;
- const activeId = tabsStore.activeConversationId;
- if (activeId !== null) {
- tabsStore.setModel(activeId, data.model);
- chatStores.get(activeId)?.setModel(data.model);
- } else {
- draftStore.setModel(data.model);
- }
- }
- } catch (err) {
- reportError("Failed to load model", err);
- }
- }
-
- // The workspace conversation's persisted reasoning effort. Seeded from the
- // backend on focus change; null = never set (the server default applies).
- let reasoningEffort = $state<ReasoningEffort | null>(null);
-
- /** Refetch the workspace conversation's reasoning effort (works for a draft too). */
- async function refreshReasoningEffort(): Promise<void> {
- const id = workspaceConversationId();
- // Clear immediately so a switch never shows the PREVIOUS conversation's level
- // while the fetch is in flight (null renders as the server default).
- reasoningEffort = null;
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`,
- );
- if (!res.ok) return;
- const data = (await res.json()) as ReasoningEffortResponse;
- // Guard a slow response losing a race with a conversation switch.
- if (workspaceConversationId() === id) reasoningEffort = data.reasoningEffort ?? null;
- } catch (err) {
- reportError("Failed to load reasoning effort", err);
- }
- }
-
- // The workspace conversation's auto-compact percent. Seeded from the
- // backend on focus change; null = not yet fetched. 0 = disabled.
- let compactPercent = $state<number | null>(null);
-
- /** Refetch the workspace conversation's compact percent (works for a draft too). */
- async function refreshCompactPercent(): Promise<void> {
- const id = workspaceConversationId();
- compactPercent = null;
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`,
- );
- if (!res.ok) return;
- const data = (await res.json()) as CompactPercentResponse;
- if (workspaceConversationId() === id) compactPercent = data.threshold;
- } catch (err) {
- reportError("Failed to load compact percent", err);
- }
- }
-
- function getActiveChat(): ChatStore {
- const activeId = tabsStore.activeConversationId;
- if (activeId === null) {
- return draftStore;
- }
- return chatStores.get(activeId) ?? draftStore;
- }
-
- function refreshActiveChat(): void {
- activeChat = getActiveChat();
- }
-
- function handleChatMessage(msg: ChatDeltaMessage | ChatErrorMessage): void {
- let targetId: string | undefined;
- if (msg.type === "chat.delta") {
- targetId = msg.event.conversationId;
- } else {
- targetId = msg.conversationId;
- }
-
- if (targetId !== undefined) {
- const store = chatStores.get(targetId) ?? watchStores.get(targetId);
- if (store !== undefined) {
- store.handleDelta(msg);
- return;
- }
- }
-
- // fallback: try all stores (chat.error without conversationId)
- for (const store of chatStores.values()) {
- store.handleDelta(msg);
- }
- for (const store of watchStores.values()) {
- store.handleDelta(msg);
- }
- }
-
- /**
- * Start watching a conversation's live turn events (`chat.subscribe`). Sent for
- * EVERY open conversation — not just the active one — so a backgrounded tab keeps
- * streaming a running turn, and a reloaded/second client re-attaches to an
- * in-flight turn (the server replays it from `turn-start`). Idempotent server-side;
- * the socket queues it until the connection is open. NOT needed right after
- * `chat.send` (that auto-subscribes the sending connection).
- */
- function subscribeChat(conversationId: string): void {
- socket?.send({ type: "chat.subscribe", conversationId });
- }
-
- /** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */
- function unsubscribeChat(conversationId: string): void {
- socket?.send({ type: "chat.unsubscribe", conversationId });
- }
-
- /**
- * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
- * modal). Returns a live {@link ChatStore} for the conversation's turn stream.
- * If the conversation is already an open TAB, reuses its store (it is already
- * subscribed + streaming); otherwise creates an EPHEMERAL watch store in
- * `watchStores` (separate from tabs — never opens a tab), subscribes to its
- * live turn stream, and loads history. Deltas route to it via `handleChatMessage`.
- * Pair with {@link unwatchConversation} on close.
- */
- function watchConversation(conversationId: string): ChatStore {
- // An open tab already has a live store + subscription — reuse it.
- const tabStore = chatStores.get(conversationId);
- if (tabStore !== undefined) return tabStore;
- const existing = watchStores.get(conversationId);
- if (existing !== undefined) return existing;
- const store = createChatFor(conversationId, activeModel, activeWorkspaceId);
- watchStores.set(conversationId, store);
- void store.load();
- subscribeChat(conversationId);
- return store;
- }
-
- /**
- * Dispose + unsubscribe a watch opened by {@link watchConversation}. A no-op if
- * the conversation was (or became) an open TAB — the tab owns its store +
- * subscription, so nothing is torn down (closing the modal must not disturb the
- * tab strip). Only the ephemeral watch store is disposed + unsubscribed.
- */
- function unwatchConversation(conversationId: string): void {
- // A tab reuses its own store — leave it (and its subscription) intact.
- if (chatStores.has(conversationId)) return;
- const store = watchStores.get(conversationId);
- if (store === undefined) return;
- store.dispose();
- watchStores.delete(conversationId);
- unsubscribeChat(conversationId);
- }
-
- /**
- * Tell the backend the user EXPLICITLY closed this conversation's tab
- * (`POST /conversations/:id/close`): aborts any in-flight turn (it seals with
- * `reason: "aborted"`) and stops + DISABLES its cache-warming (persisted OFF).
- * Distinct from a disconnect / `chat.unsubscribe`, which deliberately leave
- * both running. Fire-and-forget: a failure is non-fatal (worst case the
- * warming keeps running until a later close/toggle), and the endpoint is
- * idempotent server-side.
- */
- function closeConversation(conversationId: string): void {
- void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/close`, {
- method: "POST",
- }).catch((err) => {
- reportError("Failed to close conversation", err);
- });
- }
-
- /** The conversation the surfaces should scope to (undefined for a draft). */
- function focusedConversationId(): string | undefined {
- return tabsStore.activeConversationId ?? undefined;
- }
-
- /**
- * The conversation id workspace settings (cwd / LSP) target: the active tab, or
- * the pending draft's id when in draft mode. Unlike `focusedConversationId`, this
- * is NEVER undefined — the draft has a stable client-minted id that survives
- * promotion (first send), so a cwd set on a draft carries into the real turn.
- */
- function workspaceConversationId(): string {
- return tabsStore.activeConversationId ?? draftConversationId;
- }
-
- function handleServerMessage(msg: SurfaceServerMessage): void {
- protocol = applyServerMessage(protocol, msg);
- // Surfaces are auto-expanded: whenever the catalog changes, subscribe to
- // every entry (and drop subscriptions for entries that vanished).
- if (msg.type === "catalog") {
- syncSubscriptions();
- }
- }
-
- /**
- * Subscribe to every catalog entry, scoped to the focused conversation, and
- * unsubscribe stragglers. Re-run on conversation switch: a conversation-scoped
- * surface (e.g. cache-warming) re-scopes to the new id (`protocolSubscribe`
- * emits unsubscribe-old + subscribe-new); a global surface ignores the id.
- */
- function syncSubscriptions(): void {
- const cid = focusedConversationId();
- for (const entry of protocol.catalog) {
- // A GLOBAL surface ignores conversation scope — subscribe it WITHOUT an id
- // so a conversation switch doesn't churn a redundant unsubscribe+subscribe
- // round trip ([email protected] catalog `scope`; ABSENT = assume
- // conversation-scoped, the conservative pre-0.2.0 policy).
- const scoped = entry.scope === "global" ? undefined : cid;
- const result = protocolSubscribe(protocol, entry.id, scoped);
- protocol = result.state;
- for (const msg of result.outgoing) {
- socket?.send(msg);
- }
- }
- const catalogIds = new Set(protocol.catalog.map((e) => e.id));
- for (const id of [...protocol.subscriptions.keys()]) {
- if (!catalogIds.has(id)) {
- const result = protocolUnsubscribe(protocol, id);
- protocol = result.state;
- for (const msg of result.outgoing) {
- socket?.send(msg);
- }
- }
- }
- }
-
- let socket: ReturnType<typeof createSurfaceSocket> | null = null;
-
- /**
- * Open a conversation tab — used by the `conversation.open` WS broadcast
- * (CLI `--open` flag) and by `conversation.statusChanged` when a new active
- * conversation is discovered. If the conversation is already open, this is a
- * no-op; otherwise create a chat store, load its history, subscribe to its live
- * turns, and add the tab WITHOUT switching the active conversation (the user
- * stays on their current tab; the new tab appears in the strip). The tab is
- * stamped with the conversation's actual `workspaceId`, NOT the viewer's
- * currently active workspace.
- */
- function openConversation(conversationId: string, workspaceId: string): void {
- if (chatStores.has(conversationId)) return;
- const store = createChatFor(conversationId, activeModel, workspaceId);
- chatStores.set(conversationId, store);
- void store.load();
- subscribeChat(conversationId);
- tabsStore.openTab({
- conversationId,
- model: activeModel,
- title: "Conversation",
- workspaceId,
- });
- }
-
- /**
- * Remove a tab + its chat store locally (NO `POST /close` — used when the
- * backend already marked the conversation `closed` via `conversation.statusChanged`).
- */
- function removeTabLocally(conversationId: string): void {
- unsubscribeChat(conversationId);
- const store = chatStores.get(conversationId);
- if (store !== undefined) {
- store.dispose();
- chatStores.delete(conversationId);
- }
- void cache.delete(conversationId);
- tabsStore.closeTab(conversationId);
- conversationStatuses.delete(conversationId);
- refreshActiveChat();
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
- void refreshReasoningEffort();
- void refreshCompactPercent();
- }
-
- /**
- * Surface a swallowed error to the user via the full-screen error modal
- * (`fatalError` → `ErrorModal`). Logs to `console.error` too so the stack is
- * in devtools. Called from catch blocks that previously swallowed errors silently.
- */
- function reportError(context: string, err: unknown): void {
- console.error(`[reportError] ${context}`, err);
- const detail =
- err instanceof Error
- ? `${err.name}: ${err.message}\n\n${err.stack ?? "(no stack trace available)"}`
- : String(err);
- fatalError = `${context}\n\n${detail}`;
- }
-
- // Conversation lifecycle status (backend-owned, pushed via WS +
- // fetched on connect). Keyed by conversationId.
- let conversationStatuses = $state<Map<string, ConversationStatus>>(new Map());
-
- /**
- * Fetch `GET /conversations?status=active,idle` on connect to restore the
- * tab bar across devices. Merges: opens tabs for conversations not already
- * open, removes tabs for conversations that are no longer active/idle
- * (closed on another device), and subscribes to `active` conversations'
- * live streams.
- */
- async function fetchOpenConversations(): Promise<void> {
- try {
- const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`);
- if (!res.ok) return;
- const data = (await res.json()) as ConversationListResponse;
-
- // Update the status map from the authoritative backend list.
- const newStatuses = new Map<string, ConversationStatus>();
- for (const conv of data.conversations) {
- newStatuses.set(conv.id, conv.status);
- }
- conversationStatuses = newStatuses;
-
- // Open tabs for conversations not already open.
- const existingIds = new Set(chatStores.keys());
- for (const conv of data.conversations) {
- if (!existingIds.has(conv.id)) {
- const store = createChatFor(conv.id, activeModel, conv.workspaceId);
- chatStores.set(conv.id, store);
- void store.load();
- subscribeChat(conv.id);
- tabsStore.openTab({
- conversationId: conv.id,
- model: activeModel,
- title: conv.title,
- workspaceId: conv.workspaceId,
- });
- } else {
- // Already open — update the title from the backend if it differs.
- tabsStore.setTitle(conv.id, conv.title);
- }
- }
-
- // Remove tabs for conversations no longer active/idle (closed elsewhere).
- const backendIds = new Set(data.conversations.map((c) => c.id));
- for (const tab of tabsStore.tabs) {
- if (!backendIds.has(tab.conversationId)) {
- removeTabLocally(tab.conversationId);
- }
- }
- } catch (err) {
- reportError(
- `Failed to load conversations from the backend.\n\nURL: ${httpBase}/conversations?status=active,idle`,
- err,
- );
- }
- }
-
- const socketOpts: SurfaceSocketOptions = {
- url: wsUrl,
- onMessage: handleServerMessage,
- onChat: handleChatMessage,
- onConversationOpen(msg: ConversationOpenMessage): void {
- openConversation(msg.conversationId, msg.workspaceId);
- },
- onConversationStatusChanged(msg: ConversationStatusChangedMessage): void {
- const { conversationId, status, workspaceId } = msg;
- if (status === "closed") {
- // Closed on another device (or the backend) — remove the tab locally.
- if (chatStores.has(conversationId)) {
- removeTabLocally(conversationId);
- }
- return;
- }
- // active / idle — update the status map (drives the tab spinner).
- conversationStatuses = new Map(conversationStatuses).set(conversationId, status);
- // If this is a new active conversation we don't have a tab for, open one.
- if (status === "active" && !chatStores.has(conversationId)) {
- openConversation(conversationId, workspaceId);
- }
- },
- onConversationCompacted(msg: ConversationCompactedMessage): void {
- // Compaction keeps the conversation ID — the old full history is forked
- // to an archive (newConversationId). Just reload the same conversation's
- // history (dispose stale store + cache + re-fetch).
- const cid = msg.conversationId;
- const wasActive = tabsStore.activeConversationId === cid;
- const store = chatStores.get(cid);
- if (store !== undefined) {
- store.dispose();
- }
- void cache.delete(cid);
- const fresh = createChatFor(cid, activeModel, activeWorkspaceId);
- chatStores.set(cid, fresh);
- void fresh.load();
- if (wasActive) {
- refreshActiveChat();
- }
- },
- onReopen() {
- // The server forgot our subscriptions on reconnect; re-send each with the
- // conversation it was subscribed under (protocolSubscribe would no-op since
- // they're still in our local map, so emit the wire messages directly).
- for (const [surfaceId, sub] of protocol.subscriptions) {
- const msg: SubscribeMessage =
- sub.conversationId === undefined
- ? { type: "subscribe", surfaceId }
- : { type: "subscribe", surfaceId, conversationId: sub.conversationId };
- socket?.send(msg);
- }
- // Re-attach to every open conversation's turn stream. A turn that kept
- // running while we were disconnected resumes streaming (server replays it
- // from `turn-start`); one that sealed while we were gone is committed from
- // history by `resync()` (which also clears a now-stale "generating").
- for (const tab of tabsStore.tabs) {
- subscribeChat(tab.conversationId);
- chatStores.get(tab.conversationId)?.resync();
- }
- // Re-attach to every MODAL watch too (a run-chat modal open across a
- // reconnect keeps streaming). Watch stores are separate from tabs.
- for (const [watchId, watchStore] of watchStores) {
- subscribeChat(watchId);
- watchStore.resync();
- }
- },
- };
- if (opts?.socketFactory !== undefined) {
- socketOpts.socketFactory = opts.socketFactory;
- }
- socket = createSurfaceSocket(socketOpts);
-
- // Fetch model catalog
- void fetchImpl(`${httpBase}/models`)
- .then((res) => {
- if (!res.ok) return;
- return res.json() as Promise<ModelsResponse>;
- })
- .then((data) => {
- if (data === undefined) return;
- models = data.models;
- modelInfo = data.modelInfo ?? {};
- if (data.models.length > 0 && !data.models.includes(activeModel)) {
- const first = data.models[0];
- if (first !== undefined) {
- activeModel = first;
- draftStore.setModel(first);
- }
- }
- })
- .catch((err) => {
- reportError("Failed to load model list", err);
- });
-
- // Fetch the discovered-computer catalog (global, like models). Empty until
- // the `ssh` extension lands — a safe no-op until then (the selector shows
- // "Local (none)" only). Non-fatal: a failure leaves an empty list.
- void fetchImpl(`${httpBase}/computers`)
- .then((res) => {
- if (!res.ok) return { computers: [] } as ComputerListResponse;
- return res.json() as Promise<ComputerListResponse>;
- })
- .then((data) => {
- computers = data?.computers ?? [];
- })
- .catch((err) => {
- reportError("Failed to load computer list", err);
- });
-
- // Restore persisted tabs
- const persistedState = storageAdapter.load();
- if (persistedState !== null && persistedState.tabs.length > 0) {
- for (const tab of persistedState.tabs) {
- const store = createChatFor(tab.conversationId, tab.model, tab.workspaceId);
- chatStores.set(tab.conversationId, store);
- void store.load();
- // Watch each restored conversation's live turns: after a reload mid-turn the
- // server replays the in-flight turn so we keep rendering it. Queued until the
- // socket opens.
- subscribeChat(tab.conversationId);
- }
- if (persistedState.activeConversationId !== null) {
- const activeTab = persistedState.tabs.find(
- (t) => t.conversationId === persistedState.activeConversationId,
- );
- if (activeTab !== undefined) {
- activeModel = activeTab.model;
- }
- }
- }
-
- refreshActiveChat();
- void refreshCwd();
- void refreshComputer();
- void refreshModel();
- void refreshReasoningEffort();
- void refreshCompactPercent();
-
- // Fetch the authoritative open-conversation list from the backend (cross-
- // device tab sync). Merges with the localStorage-restored tabs: opens new
- // ones, removes closed ones, updates titles + statuses.
- void fetchOpenConversations();
-
- return {
- get tabs(): readonly Tab[] {
- return tabsStore.tabs.filter((t) => t.workspaceId === activeWorkspaceId);
- },
- get activeConversationId(): string | null {
- return tabsStore.activeConversationId;
- },
- get activeWorkspaceId(): string {
- return activeWorkspaceId;
- },
- setActiveWorkspace(workspaceId: string): void {
- activeWorkspaceId = workspaceId;
- // Reset to a fresh draft scoped to the new workspace so a new chat is
- // stamped with the right `workspaceId` on `chat.send`.
- const nextDraftId = randomId();
- draftStore = createChatFor(nextDraftId, activeModel, workspaceId);
- draftConversationId = nextDraftId;
- tabsStore.newDraft();
- refreshActiveChat();
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
- void refreshModel();
- void refreshReasoningEffort();
- void refreshCompactPercent();
- },
- get activeChat(): ChatStore {
- return activeChat;
- },
- get models(): readonly string[] {
- return models;
- },
- get modelInfo(): Readonly<Record<string, ModelMetadata>> {
- return modelInfo;
- },
- get activeModel(): string {
- return activeModel;
- },
- get catalog() {
- return protocol.catalog;
- },
- get surfaces(): readonly SurfaceSpec[] {
- const out: SurfaceSpec[] = [];
- for (const entry of protocol.catalog) {
- const spec = getSurfaceSpec(protocol, entry.id);
- if (spec) out.push(spec);
- }
- return out;
- },
- get lastError() {
- return protocol.lastError;
- },
- get storage() {
- return localStorageOpt;
- },
- get cwd(): string | null {
- return cwd;
- },
- get computerId(): string | null {
- return computerId;
- },
- get computers(): readonly ComputerEntry[] {
- return computers;
- },
- get reasoningEffort(): ReasoningEffort | null {
- return reasoningEffort;
- },
- get compactPercent(): number | null {
- return compactPercent;
- },
- get chatLimit(): number {
- return chatLimit;
- },
- conversationStatus(conversationId: string): ConversationStatus | undefined {
- return conversationStatuses.get(conversationId);
- },
- get currentConversationId(): string {
- return workspaceConversationId();
- },
-
- surface(surfaceId: string): SurfaceSpec | null {
- return getSurfaceSpec(protocol, surfaceId);
- },
-
- send(text: string): void {
- if (tabsStore.activeConversationId === null) {
- // Draft: promote to tab on first send
- const conversationId = draftConversationId;
- const model = activeModel;
- tabsStore.createTab({
- conversationId,
- model,
- title: deriveTitle(text),
- workspaceId: activeWorkspaceId,
- });
- chatStores.set(conversationId, draftStore);
- void draftStore.load();
-
- // Prepare next draft
- const nextDraftId = randomId();
- draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId);
- draftConversationId = nextDraftId;
-
- refreshActiveChat();
- // The draft became a real conversation: re-scope conversation-scoped
- // surfaces (e.g. cache-warming) to its id.
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
- void refreshReasoningEffort();
- void refreshCompactPercent();
- // Now send on the promoted store
- chatStores.get(conversationId)?.send(text);
- } else {
- activeChat.send(text);
- }
- },
-
- queueMessage(text: string): void {
- // Only offered while generating (Composer switches to `chat.queue`
- // when `status === "running"`), so a draft (never generating) never
- // reaches here. `chat.queue` auto-starts a turn if idle, so even a race
- // (turn sealed between the status read and the send) is safe — the
- // server starts a fresh turn with the message as its opening prompt.
- activeChat.queueMessage(text);
- },
-
- selectModel(model: string): void {
- activeModel = model;
- const activeId = tabsStore.activeConversationId;
- if (activeId !== null) {
- tabsStore.setModel(activeId, model);
- chatStores.get(activeId)?.setModel(model);
- void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(activeId)}/model`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model } satisfies SetModelRequest),
- }).catch((err) => {
- reportError("Failed to persist model", err);
- });
- } else {
- draftStore.setModel(model);
- }
- },
-
- newDraft(): void {
- tabsStore.newDraft();
- const nextDraftId = randomId();
- draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId);
- draftConversationId = nextDraftId;
- refreshActiveChat();
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
- void refreshModel();
- void refreshReasoningEffort();
- void refreshCompactPercent();
- },
-
- selectTab(conversationId: string): void {
- tabsStore.selectTab(conversationId);
- const tab = tabsStore.tabs.find((t) => t.conversationId === conversationId);
- if (tab !== undefined) {
- activeModel = tab.model;
- }
- refreshActiveChat();
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
- void refreshModel();
- void refreshReasoningEffort();
- void refreshCompactPercent();
- },
-
- closeTab(conversationId: string): void {
- // The user is DONE with this chat: abort any in-flight turn + stop/disable
- // its cache-warming, server-side (POST /close sets status → "closed").
- closeConversation(conversationId);
- removeTabLocally(conversationId);
- },
-
- renameTab(conversationId: string, title: string): void {
- tabsStore.setTitle(conversationId, title);
- void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/title`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title } satisfies SetTitleRequest),
- }).catch((err) => {
- reportError("Failed to rename conversation", err);
- });
- },
-
- invoke(surfaceId: string, actionId: string, payload?: unknown): void {
- const result = protocolInvoke(
- protocol,
- surfaceId,
- actionId,
- payload,
- focusedConversationId(),
- );
- protocol = result.state;
- for (const msg of result.outgoing) {
- socket?.send(msg);
- }
- },
-
- async warmNow(): Promise<WarmResult | null> {
- const conversationId = tabsStore.activeConversationId;
- if (conversationId === null) return null;
- const body: WarmRequest = { conversationId, model: activeModel };
- try {
- const res = await fetchImpl(`${httpBase}/chat/warm`, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return { ok: false, error: errBody?.error ?? `Warm failed (HTTP ${res.status})` };
- }
- return { ok: true, response: (await res.json()) as WarmResponse };
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : "Warm request failed" };
- }
- },
-
- async setCwd(value: string): Promise<CwdResult | null> {
- const id = workspaceConversationId();
- const body: SetCwdRequest = {
- cwd: value,
- workspaceId: untrack(() => activeWorkspaceId),
- };
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`, {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return { ok: false, error: errBody?.error ?? `Set cwd failed (HTTP ${res.status})` };
- }
- const data = (await res.json()) as CwdResponse;
- const next = data.cwd ?? null;
- if (workspaceConversationId() === id) cwd = next;
- return { ok: true, cwd: next };
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : "Set cwd request failed" };
- }
- },
-
- async setComputer(computerIdValue: string | null): Promise<ComputerResult | null> {
- const id = workspaceConversationId();
- const body: SetConversationComputerRequest = { computerId: computerIdValue };
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(id)}/computer`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set computer failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as ConversationComputerResponse;
- const next = data.computerId ?? null;
- if (workspaceConversationId() === id) computerId = next;
- return { ok: true, computerId: next };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set computer request failed",
- };
- }
- },
-
- async computerStatus(alias: string): Promise<ComputerStatusResult | null> {
- if (alias === "") return null;
- try {
- const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/status`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Computer status failed (HTTP ${res.status})`,
- };
- }
- const status = (await res.json()) as ComputerStatusResponse;
- return { ok: true, response: status };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Computer status request failed",
- };
- }
- },
-
- async testComputer(alias: string): Promise<TestComputerResult | null> {
- if (alias === "") return null;
- try {
- const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/test`, {
- method: "POST",
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Test computer failed (HTTP ${res.status})`,
- };
- }
- const response = (await res.json()) as TestComputerResponse;
- return { ok: true, response };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Test computer request failed",
- };
- }
- },
-
- async setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null> {
- const id = workspaceConversationId();
- const body: SetReasoningEffortRequest = { reasoningEffort: level };
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set reasoning effort failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as ReasoningEffortResponse;
- const next = data.reasoningEffort ?? level;
- if (workspaceConversationId() === id) reasoningEffort = next;
- return { ok: true, reasoningEffort: next };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set reasoning effort request failed",
- };
- }
- },
-
- stopGeneration(): void {
- const conversationId = tabsStore.activeConversationId;
- if (conversationId === null) return;
- void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/stop`, {
- method: "POST",
- }).catch((err) => {
- reportError("Failed to stop generation", err);
- });
- },
-
- async compactNow(keepLastN?: number): Promise<CompactResult | null> {
- const conversationId = tabsStore.activeConversationId;
- if (conversationId === null) return null;
- const body: Record<string, unknown> = {};
- if (keepLastN !== undefined) body.keepLastN = keepLastN;
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(conversationId)}/compact`,
- {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Compact failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as CompactResponse;
- return { ok: true, response: data };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Compact request failed",
- };
- }
- },
-
- async setCompactPercent(percent: number): Promise<CompactPercentResult | null> {
- const id = workspaceConversationId();
- const body: SetCompactPercentRequest = { threshold: percent };
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set compact percent failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as CompactPercentResponse;
- if (workspaceConversationId() === id) compactPercent = data.threshold;
- return { ok: true, percent: data.threshold };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set compact percent request failed",
- };
- }
- },
-
- async setChatLimit(limit: number): Promise<ChatLimitResult> {
- const next = normalizeChatLimit(limit);
- chatLimitStore.save(next);
- chatLimit = next;
- // Propagate to every live chat store. The ACTIVE one is awaited so its
- // refill (on a raise) lands before the caller returns — letting the
- // shell preserve scroll over the prepended older chunks. Background
- // stores refill fire-and-forget. Future stores pick the new limit up at
- // creation (via the persisted store).
- const active = getActiveChat();
- await active.setChatLimit(next);
- for (const s of chatStores.values()) {
- if (s !== active) void s.setChatLimit(next);
- }
- if (draftStore !== active) void draftStore.setChatLimit(next);
- return { ok: true, chatLimit: next };
- },
-
- async lspStatus(): Promise<LspResult | null> {
- const id = workspaceConversationId();
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/lsp`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return { ok: false, error: errBody?.error ?? `LSP status failed (HTTP ${res.status})` };
- }
- // Normalize the untyped body at this network seam so a malformed/partial
- // response can never crash the renderer (servers is guaranteed an array).
- const data = (await res.json()) as Partial<LspStatusResponse>;
- const response: LspStatusResponse = {
- conversationId: data.conversationId ?? id,
- cwd: data.cwd ?? null,
- servers: Array.isArray(data.servers) ? data.servers : [],
- };
- return { ok: true, response };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "LSP status request failed",
- };
- }
- },
-
- async mcpStatus(): Promise<McpResult | null> {
- const id = workspaceConversationId();
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/mcp`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return { ok: false, error: errBody?.error ?? `MCP status failed (HTTP ${res.status})` };
- }
- // Normalize the untyped body at this network seam so a malformed/partial
- // response can never crash the renderer (servers is guaranteed an array).
- const data = (await res.json()) as Partial<McpStatusResponse>;
- const response: McpStatusResponse = {
- conversationId: data.conversationId ?? id,
- cwd: data.cwd ?? null,
- servers: Array.isArray(data.servers) ? data.servers : [],
- };
- return { ok: true, response };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "MCP status request failed",
- };
- }
- },
-
- async heartbeatConfig(): Promise<HeartbeatConfigResult> {
- // Workspace-scoped (NOT per-conversation): use the active workspace id.
- const wsId = untrack(() => activeWorkspaceId);
- try {
- const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Heartbeat config failed (HTTP ${res.status})`,
- };
- }
- // Normalize the untyped JSON at the network seam (pure helper) so a
- // malformed/partial response can never crash the renderer.
- const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json());
- return { ok: true, config };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Heartbeat config request failed",
- };
- }
- },
-
- async setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult> {
- const wsId = untrack(() => activeWorkspaceId);
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(patch),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set heartbeat config failed (HTTP ${res.status})`,
- };
- }
- const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json());
- return { ok: true, config };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set heartbeat config request failed",
- };
- }
- },
-
- async heartbeatRuns(): Promise<HeartbeatRunsResult> {
- const wsId = untrack(() => activeWorkspaceId);
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs`,
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Heartbeat runs failed (HTTP ${res.status})`,
- };
- }
- const runs: readonly HeartbeatRun[] = normalizeHeartbeatRuns(await res.json());
- return { ok: true, runs };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Heartbeat runs request failed",
- };
- }
- },
-
- async stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> {
- const wsId = untrack(() => activeWorkspaceId);
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs/${encodeURIComponent(runId)}/stop`,
- { method: "POST" },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Stop heartbeat run failed (HTTP ${res.status})`,
- };
- }
- return { ok: true };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Stop heartbeat run request failed",
- };
- }
- },
-
- async heartbeatNextRun(): Promise<HeartbeatNextRunResult> {
- const wsId = untrack(() => activeWorkspaceId);
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/next-run`,
- );
- if (!res.ok) {
- // 404 = the backend hasn't shipped CR-HB-3 yet → the FE falls back to
- // an approximation. Surface as ok:false (non-fatal).
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Heartbeat next-run failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json().catch(() => null)) as { nextRunAt?: string | null } | null;
- // `null` (disabled / no run scheduled) passes through; anything non-string
- // also becomes null so a malformed body can't crash the countdown.
- const raw = data?.nextRunAt;
- const nextRunAt = typeof raw === "string" ? raw : null;
- return { ok: true, nextRunAt };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Heartbeat next-run request failed",
- };
- }
- },
-
- watchConversation(conversationId: string): ChatStore {
- return watchConversation(conversationId);
- },
-
- unwatchConversation(conversationId: string): void {
- unwatchConversation(conversationId);
- },
-
- async loadSystemPrompt(): Promise<SystemPromptLoadResult> {
- try {
- const res = await fetchImpl(`${httpBase}/system-prompt`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Load system prompt failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as SystemPromptTemplateResponse;
- return { ok: true, template: data.template ?? "" };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Load system prompt request failed",
- };
- }
- },
-
- async setSystemPrompt(template: string): Promise<SystemPromptSaveResult> {
- try {
- const body: SetSystemPromptTemplateRequest = { template };
- const res = await fetchImpl(`${httpBase}/system-prompt`, {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set system prompt failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as SystemPromptTemplateResponse;
- return { ok: true, template: data.template };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set system prompt request failed",
- };
- }
- },
-
- async loadSystemPromptVariables(): Promise<SystemPromptVariablesResult> {
- try {
- const res = await fetchImpl(`${httpBase}/system-prompt/variables`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Load system prompt variables failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as Partial<SystemPromptVariablesResponse>;
- return { ok: true, variables: Array.isArray(data.variables) ? data.variables : [] };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Load system prompt variables request failed",
- };
- }
- },
-
- attachUnloadGate(gate: () => boolean): void {
- unloadGate = gate;
- },
-
- get fatalError(): string | null {
- return fatalError;
- },
- clearFatalError(): void {
- fatalError = null;
- },
-
- dispose(): void {
- for (const store of chatStores.values()) {
- store.dispose();
- }
- chatStores.clear();
- for (const store of watchStores.values()) {
- store.dispose();
- }
- watchStores.clear();
- draftStore.dispose();
- socket?.close();
- socket = null;
- },
- };
+ let protocol = $state<ProtocolState>(protocolInitialState());
+ let models = $state<readonly string[]>([]);
+ let modelInfo = $state<Readonly<Record<string, ModelMetadata>>>({});
+ // Discovered SSH computers (`GET /computers`). Global (like `models`); empty
+ // until the `ssh` extension lands. Read-only — no CRUD (the user edits their
+ // `~/.ssh/config`).
+ let computers = $state<readonly ComputerEntry[]>([]);
+ let activeModel = $state(DEFAULT_MODEL);
+ let fatalError = $state<string | null>(null);
+
+ // The workspace currently in view (its URL slug); "default" until routing
+ // sets it. Tabs are filtered to this workspace; a new conversation is stamped
+ // with it on `chat.send`.
+ let activeWorkspaceId = $state<string>(opts?.workspaceId ?? "default");
+
+ const wsLocation = typeof location !== "undefined" ? location : undefined;
+ const wsUrl =
+ opts?.url ??
+ resolveWsUrl(
+ { VITE_WS_URL: import.meta.env.VITE_WS_URL, VITE_WS_PORT: import.meta.env.VITE_WS_PORT },
+ wsLocation,
+ );
+
+ const httpLocation = typeof location !== "undefined" ? location : undefined;
+ const httpBase =
+ opts?.httpUrl ??
+ resolveHttpUrl(
+ {
+ VITE_HTTP_URL: import.meta.env.VITE_HTTP_URL,
+ VITE_HTTP_PORT: import.meta.env.VITE_HTTP_PORT,
+ },
+ httpLocation,
+ );
+
+ const fetchImpl = opts?.fetchImpl ?? globalThis.fetch.bind(globalThis);
+ const indexedDBFactory = opts?.indexedDB ?? globalThis.indexedDB;
+ const localStorageOpt = opts?.localStorage ?? globalThis.localStorage;
+
+ const storageAdapter = createLocalStore<TabsState>("dispatch.tabs", {
+ storage: localStorageOpt,
+ });
+ const tabsStore: TabsStore = createTabsStore(storageAdapter);
+
+ // The chat limit (max loaded chunks per conversation) — a persisted local
+ // setting surfaced in the sidebar's Settings view. Reactive so the field +
+ // any live-apply re-trim update together. The default is written back on
+ // first run so the knob is discoverable in localStorage too.
+ const chatLimitStore = createLocalStore<number>("dispatch.chatLimit", {
+ storage: localStorageOpt,
+ });
+ const storedChatLimit = chatLimitStore.load();
+ const normalizedChatLimit = normalizeChatLimit(storedChatLimit);
+ let chatLimit = $state(normalizedChatLimit);
+ if (storedChatLimit === null) {
+ chatLimitStore.save(normalizedChatLimit);
+ }
+
+ // Unload gate — attached by the shell once it owns the scroll region (see
+ // `AppStore.attachUnloadGate`). Until then, unloading is allowed.
+ let unloadGate: (() => boolean) | null = null;
+
+ const cache: ConversationCache = createConversationCache(
+ createIdbChunkStore({ indexedDB: indexedDBFactory }),
+ );
+
+ const historySync = createHistorySync(httpBase, fetchImpl);
+ const metricsSync = createMetricsSync(httpBase, fetchImpl);
+
+ const chatStores = new Map<string, ChatStore>();
+
+ // Ephemeral chat stores for MODAL viewers (the heartbeat run-chat modal): a
+ // watch on a conversation's live turn stream WITHOUT opening a tab. Separate
+ // from `chatStores` (tabs) so closing a modal never disturbs the tab strip,
+ // and a tab's conversation reuses its own store (see `watchConversation`).
+ // Deltas are routed here in addition to `chatStores`.
+ const watchStores = new Map<string, ChatStore>();
+
+ function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore {
+ return createChatStore({
+ conversationId,
+ model,
+ workspaceId,
+ transport: {
+ send(msg) {
+ socket?.send(msg);
+ },
+ },
+ historySync,
+ metricsSync,
+ cache,
+ // Read from the persisted store (kept in sync with the reactive `chatLimit`
+ // by `setChatLimit` + boot) so this snapshot doesn't reference the `$state`
+ // — each store captures its limit at creation; live updates go through
+ // `setChatLimit`.
+ chatLimit: normalizeChatLimit(chatLimitStore.load()),
+ canUnload: () => (unloadGate === null ? true : unloadGate()),
+ onError: (context, err) => {
+ reportError(`${context} (conversation: ${conversationId})`, err);
+ },
+ });
+ }
+
+ const initialDraftId = randomId();
+ // Read `activeWorkspaceId` with untrack to suppress Svelte's
+ // `state_referenced_locally` warning — this intentionally captures the
+ // INITIAL workspace for the boot draft. When the workspace changes later,
+ // `setActiveWorkspace` creates a fresh draft store with the new id.
+ let draftStore: ChatStore = createChatFor(
+ initialDraftId,
+ DEFAULT_MODEL,
+ untrack(() => activeWorkspaceId),
+ );
+ let draftConversationId: string = initialDraftId;
+
+ let activeChat = $state<ChatStore>(draftStore as ChatStore);
+
+ // The active conversation's persisted working directory (per-tab). Seeded from
+ // the backend on focus change; null for a draft / when unset.
+ let cwd = $state<string | null>(null);
+
+ /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */
+ async function refreshCwd(): Promise<void> {
+ const id = workspaceConversationId();
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`);
+ if (!res.ok) return;
+ const data = (await res.json()) as CwdResponse;
+ // Guard a slow response losing a race with a conversation switch.
+ if (workspaceConversationId() === id) cwd = data.cwd ?? null;
+ } catch (err) {
+ reportError("Failed to load working directory", err);
+ }
+ }
+
+ // The active conversation's persisted computer (SSH Host alias). Seeded on
+ // focus change; null = local / never set (inherits the workspace default).
+ let computerId = $state<string | null>(null);
+
+ /**
+ * Refetch the workspace conversation's persisted computer into reactive state
+ * (works for a draft too). A draft's id 404s until promoted; `res.ok` is false
+ * so it is a silent no-op (mirrors `refreshCwd` for a draft).
+ */
+ async function refreshComputer(): Promise<void> {
+ const id = workspaceConversationId();
+ // Clear immediately so a switch never shows the PREVIOUS conversation's
+ // computer while the fetch is in flight (null renders as "Local").
+ computerId = null;
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/computer`);
+ if (!res.ok) return;
+ const data = (await res.json()) as ConversationComputerResponse;
+ // Guard a slow response losing a race with a conversation switch.
+ if (workspaceConversationId() === id) computerId = data.computerId ?? null;
+ } catch (err) {
+ reportError("Failed to load computer", err);
+ }
+ }
+
+ /** Refetch the workspace conversation's persisted model (works for a draft too). */
+ async function refreshModel(): Promise<void> {
+ const id = workspaceConversationId();
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/model`);
+ if (!res.ok) return;
+ const data = (await res.json()) as ModelResponse;
+ if (workspaceConversationId() !== id) return;
+ if (typeof data.model === "string" && data.model.length > 0) {
+ activeModel = data.model;
+ const activeId = tabsStore.activeConversationId;
+ if (activeId !== null) {
+ tabsStore.setModel(activeId, data.model);
+ chatStores.get(activeId)?.setModel(data.model);
+ } else {
+ draftStore.setModel(data.model);
+ }
+ }
+ } catch (err) {
+ reportError("Failed to load model", err);
+ }
+ }
+
+ // The workspace conversation's persisted reasoning effort. Seeded from the
+ // backend on focus change; null = never set (the server default applies).
+ let reasoningEffort = $state<ReasoningEffort | null>(null);
+
+ /** Refetch the workspace conversation's reasoning effort (works for a draft too). */
+ async function refreshReasoningEffort(): Promise<void> {
+ const id = workspaceConversationId();
+ // Clear immediately so a switch never shows the PREVIOUS conversation's level
+ // while the fetch is in flight (null renders as the server default).
+ reasoningEffort = null;
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`,
+ );
+ if (!res.ok) return;
+ const data = (await res.json()) as ReasoningEffortResponse;
+ // Guard a slow response losing a race with a conversation switch.
+ if (workspaceConversationId() === id) reasoningEffort = data.reasoningEffort ?? null;
+ } catch (err) {
+ reportError("Failed to load reasoning effort", err);
+ }
+ }
+
+ // The workspace conversation's auto-compact percent. Seeded from the
+ // backend on focus change; null = not yet fetched. 0 = disabled.
+ let compactPercent = $state<number | null>(null);
+
+ /** Refetch the workspace conversation's compact percent (works for a draft too). */
+ async function refreshCompactPercent(): Promise<void> {
+ const id = workspaceConversationId();
+ compactPercent = null;
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`,
+ );
+ if (!res.ok) return;
+ const data = (await res.json()) as CompactPercentResponse;
+ if (workspaceConversationId() === id) compactPercent = data.threshold;
+ } catch (err) {
+ reportError("Failed to load compact percent", err);
+ }
+ }
+
+ function getActiveChat(): ChatStore {
+ const activeId = tabsStore.activeConversationId;
+ if (activeId === null) {
+ return draftStore;
+ }
+ return chatStores.get(activeId) ?? draftStore;
+ }
+
+ function refreshActiveChat(): void {
+ activeChat = getActiveChat();
+ }
+
+ function handleChatMessage(msg: ChatDeltaMessage | ChatErrorMessage): void {
+ let targetId: string | undefined;
+ if (msg.type === "chat.delta") {
+ targetId = msg.event.conversationId;
+ } else {
+ targetId = msg.conversationId;
+ }
+
+ if (targetId !== undefined) {
+ const store = chatStores.get(targetId) ?? watchStores.get(targetId);
+ if (store !== undefined) {
+ store.handleDelta(msg);
+ return;
+ }
+ }
+
+ // fallback: try all stores (chat.error without conversationId)
+ for (const store of chatStores.values()) {
+ store.handleDelta(msg);
+ }
+ for (const store of watchStores.values()) {
+ store.handleDelta(msg);
+ }
+ }
+
+ /**
+ * Start watching a conversation's live turn events (`chat.subscribe`). Sent for
+ * EVERY open conversation — not just the active one — so a backgrounded tab keeps
+ * streaming a running turn, and a reloaded/second client re-attaches to an
+ * in-flight turn (the server replays it from `turn-start`). Idempotent server-side;
+ * the socket queues it until the connection is open. NOT needed right after
+ * `chat.send` (that auto-subscribes the sending connection).
+ */
+ function subscribeChat(conversationId: string): void {
+ socket?.send({ type: "chat.subscribe", conversationId });
+ }
+
+ /** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */
+ function unsubscribeChat(conversationId: string): void {
+ socket?.send({ type: "chat.unsubscribe", conversationId });
+ }
+
+ /**
+ * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
+ * modal). Returns a live {@link ChatStore} for the conversation's turn stream.
+ * If the conversation is already an open TAB, reuses its store (it is already
+ * subscribed + streaming); otherwise creates an EPHEMERAL watch store in
+ * `watchStores` (separate from tabs — never opens a tab), subscribes to its
+ * live turn stream, and loads history. Deltas route to it via `handleChatMessage`.
+ * Pair with {@link unwatchConversation} on close.
+ */
+ function watchConversation(conversationId: string): ChatStore {
+ // An open tab already has a live store + subscription — reuse it.
+ const tabStore = chatStores.get(conversationId);
+ if (tabStore !== undefined) return tabStore;
+ const existing = watchStores.get(conversationId);
+ if (existing !== undefined) return existing;
+ const store = createChatFor(conversationId, activeModel, activeWorkspaceId);
+ watchStores.set(conversationId, store);
+ void store.load();
+ subscribeChat(conversationId);
+ return store;
+ }
+
+ /**
+ * Dispose + unsubscribe a watch opened by {@link watchConversation}. A no-op if
+ * the conversation was (or became) an open TAB — the tab owns its store +
+ * subscription, so nothing is torn down (closing the modal must not disturb the
+ * tab strip). Only the ephemeral watch store is disposed + unsubscribed.
+ */
+ function unwatchConversation(conversationId: string): void {
+ // A tab reuses its own store — leave it (and its subscription) intact.
+ if (chatStores.has(conversationId)) return;
+ const store = watchStores.get(conversationId);
+ if (store === undefined) return;
+ store.dispose();
+ watchStores.delete(conversationId);
+ unsubscribeChat(conversationId);
+ }
+
+ /**
+ * Tell the backend the user EXPLICITLY closed this conversation's tab
+ * (`POST /conversations/:id/close`): aborts any in-flight turn (it seals with
+ * `reason: "aborted"`) and stops + DISABLES its cache-warming (persisted OFF).
+ * Distinct from a disconnect / `chat.unsubscribe`, which deliberately leave
+ * both running. Fire-and-forget: a failure is non-fatal (worst case the
+ * warming keeps running until a later close/toggle), and the endpoint is
+ * idempotent server-side.
+ */
+ function closeConversation(conversationId: string): void {
+ void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/close`, {
+ method: "POST",
+ }).catch((err) => {
+ reportError("Failed to close conversation", err);
+ });
+ }
+
+ /** The conversation the surfaces should scope to (undefined for a draft). */
+ function focusedConversationId(): string | undefined {
+ return tabsStore.activeConversationId ?? undefined;
+ }
+
+ /**
+ * The conversation id workspace settings (cwd / LSP) target: the active tab, or
+ * the pending draft's id when in draft mode. Unlike `focusedConversationId`, this
+ * is NEVER undefined — the draft has a stable client-minted id that survives
+ * promotion (first send), so a cwd set on a draft carries into the real turn.
+ */
+ function workspaceConversationId(): string {
+ return tabsStore.activeConversationId ?? draftConversationId;
+ }
+
+ function handleServerMessage(msg: SurfaceServerMessage): void {
+ protocol = applyServerMessage(protocol, msg);
+ // Surfaces are auto-expanded: whenever the catalog changes, subscribe to
+ // every entry (and drop subscriptions for entries that vanished).
+ if (msg.type === "catalog") {
+ syncSubscriptions();
+ }
+ }
+
+ /**
+ * Subscribe to every catalog entry, scoped to the focused conversation, and
+ * unsubscribe stragglers. Re-run on conversation switch: a conversation-scoped
+ * surface (e.g. cache-warming) re-scopes to the new id (`protocolSubscribe`
+ * emits unsubscribe-old + subscribe-new); a global surface ignores the id.
+ */
+ function syncSubscriptions(): void {
+ const cid = focusedConversationId();
+ for (const entry of protocol.catalog) {
+ // A GLOBAL surface ignores conversation scope — subscribe it WITHOUT an id
+ // so a conversation switch doesn't churn a redundant unsubscribe+subscribe
+ // round trip ([email protected] catalog `scope`; ABSENT = assume
+ // conversation-scoped, the conservative pre-0.2.0 policy).
+ const scoped = entry.scope === "global" ? undefined : cid;
+ const result = protocolSubscribe(protocol, entry.id, scoped);
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ socket?.send(msg);
+ }
+ }
+ const catalogIds = new Set(protocol.catalog.map((e) => e.id));
+ for (const id of [...protocol.subscriptions.keys()]) {
+ if (!catalogIds.has(id)) {
+ const result = protocolUnsubscribe(protocol, id);
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ socket?.send(msg);
+ }
+ }
+ }
+ }
+
+ let socket: ReturnType<typeof createSurfaceSocket> | null = null;
+
+ /**
+ * Open a conversation tab — used by the `conversation.open` WS broadcast
+ * (CLI `--open` flag) and by `conversation.statusChanged` when a new active
+ * conversation is discovered. If the conversation is already open, this is a
+ * no-op; otherwise create a chat store, load its history, subscribe to its live
+ * turns, and add the tab WITHOUT switching the active conversation (the user
+ * stays on their current tab; the new tab appears in the strip). The tab is
+ * stamped with the conversation's actual `workspaceId`, NOT the viewer's
+ * currently active workspace.
+ */
+ function openConversation(conversationId: string, workspaceId: string): void {
+ if (chatStores.has(conversationId)) return;
+ const store = createChatFor(conversationId, activeModel, workspaceId);
+ chatStores.set(conversationId, store);
+ void store.load();
+ subscribeChat(conversationId);
+ tabsStore.openTab({
+ conversationId,
+ model: activeModel,
+ title: "Conversation",
+ workspaceId,
+ });
+ }
+
+ /**
+ * Remove a tab + its chat store locally (NO `POST /close` — used when the
+ * backend already marked the conversation `closed` via `conversation.statusChanged`).
+ */
+ function removeTabLocally(conversationId: string): void {
+ unsubscribeChat(conversationId);
+ const store = chatStores.get(conversationId);
+ if (store !== undefined) {
+ store.dispose();
+ chatStores.delete(conversationId);
+ }
+ void cache.delete(conversationId);
+ tabsStore.closeTab(conversationId);
+ conversationStatuses.delete(conversationId);
+ refreshActiveChat();
+ syncSubscriptions();
+ void refreshCwd();
+ void refreshComputer();
+ void refreshReasoningEffort();
+ void refreshCompactPercent();
+ }
+
+ /**
+ * Surface a swallowed error to the user via the full-screen error modal
+ * (`fatalError` → `ErrorModal`). Logs to `console.error` too so the stack is
+ * in devtools. Called from catch blocks that previously swallowed errors silently.
+ */
+ function reportError(context: string, err: unknown): void {
+ console.error(`[reportError] ${context}`, err);
+ const detail =
+ err instanceof Error
+ ? `${err.name}: ${err.message}\n\n${err.stack ?? "(no stack trace available)"}`
+ : String(err);
+ fatalError = `${context}\n\n${detail}`;
+ }
+
+ // Conversation lifecycle status (backend-owned, pushed via WS +
+ // fetched on connect). Keyed by conversationId.
+ let conversationStatuses = $state<Map<string, ConversationStatus>>(new Map());
+
+ /**
+ * Fetch `GET /conversations?status=active,idle` on connect to restore the
+ * tab bar across devices. Merges: opens tabs for conversations not already
+ * open, removes tabs for conversations that are no longer active/idle
+ * (closed on another device), and subscribes to `active` conversations'
+ * live streams.
+ */
+ async function fetchOpenConversations(): Promise<void> {
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`);
+ if (!res.ok) return;
+ const data = (await res.json()) as ConversationListResponse;
+
+ // Update the status map from the authoritative backend list.
+ const newStatuses = new Map<string, ConversationStatus>();
+ for (const conv of data.conversations) {
+ newStatuses.set(conv.id, conv.status);
+ }
+ conversationStatuses = newStatuses;
+
+ // Open tabs for conversations not already open.
+ const existingIds = new Set(chatStores.keys());
+ for (const conv of data.conversations) {
+ if (!existingIds.has(conv.id)) {
+ const store = createChatFor(conv.id, activeModel, conv.workspaceId);
+ chatStores.set(conv.id, store);
+ void store.load();
+ subscribeChat(conv.id);
+ tabsStore.openTab({
+ conversationId: conv.id,
+ model: activeModel,
+ title: conv.title,
+ workspaceId: conv.workspaceId,
+ });
+ } else {
+ // Already open — update the title from the backend if it differs.
+ tabsStore.setTitle(conv.id, conv.title);
+ }
+ }
+
+ // Remove tabs for conversations no longer active/idle (closed elsewhere).
+ const backendIds = new Set(data.conversations.map((c) => c.id));
+ for (const tab of tabsStore.tabs) {
+ if (!backendIds.has(tab.conversationId)) {
+ removeTabLocally(tab.conversationId);
+ }
+ }
+ } catch (err) {
+ reportError(
+ `Failed to load conversations from the backend.\n\nURL: ${httpBase}/conversations?status=active,idle`,
+ err,
+ );
+ }
+ }
+
+ const socketOpts: SurfaceSocketOptions = {
+ url: wsUrl,
+ onMessage: handleServerMessage,
+ onChat: handleChatMessage,
+ onConversationOpen(msg: ConversationOpenMessage): void {
+ openConversation(msg.conversationId, msg.workspaceId);
+ },
+ onConversationStatusChanged(msg: ConversationStatusChangedMessage): void {
+ const { conversationId, status, workspaceId } = msg;
+ if (status === "closed") {
+ // Closed on another device (or the backend) — remove the tab locally.
+ if (chatStores.has(conversationId)) {
+ removeTabLocally(conversationId);
+ }
+ return;
+ }
+ // active / idle — update the status map (drives the tab spinner).
+ conversationStatuses = new Map(conversationStatuses).set(conversationId, status);
+ // If this is a new active conversation we don't have a tab for, open one.
+ if (status === "active" && !chatStores.has(conversationId)) {
+ openConversation(conversationId, workspaceId);
+ }
+ },
+ onConversationCompacted(msg: ConversationCompactedMessage): void {
+ // Compaction keeps the conversation ID — the old full history is forked
+ // to an archive (newConversationId). Just reload the same conversation's
+ // history (dispose stale store + cache + re-fetch).
+ const cid = msg.conversationId;
+ const wasActive = tabsStore.activeConversationId === cid;
+ const store = chatStores.get(cid);
+ if (store !== undefined) {
+ store.dispose();
+ }
+ void cache.delete(cid);
+ const fresh = createChatFor(cid, activeModel, activeWorkspaceId);
+ chatStores.set(cid, fresh);
+ void fresh.load();
+ if (wasActive) {
+ refreshActiveChat();
+ }
+ },
+ onReopen() {
+ // The server forgot our subscriptions on reconnect; re-send each with the
+ // conversation it was subscribed under (protocolSubscribe would no-op since
+ // they're still in our local map, so emit the wire messages directly).
+ for (const [surfaceId, sub] of protocol.subscriptions) {
+ const msg: SubscribeMessage =
+ sub.conversationId === undefined
+ ? { type: "subscribe", surfaceId }
+ : { type: "subscribe", surfaceId, conversationId: sub.conversationId };
+ socket?.send(msg);
+ }
+ // Re-attach to every open conversation's turn stream. A turn that kept
+ // running while we were disconnected resumes streaming (server replays it
+ // from `turn-start`); one that sealed while we were gone is committed from
+ // history by `resync()` (which also clears a now-stale "generating").
+ for (const tab of tabsStore.tabs) {
+ subscribeChat(tab.conversationId);
+ chatStores.get(tab.conversationId)?.resync();
+ }
+ // Re-attach to every MODAL watch too (a run-chat modal open across a
+ // reconnect keeps streaming). Watch stores are separate from tabs.
+ for (const [watchId, watchStore] of watchStores) {
+ subscribeChat(watchId);
+ watchStore.resync();
+ }
+ },
+ };
+ if (opts?.socketFactory !== undefined) {
+ socketOpts.socketFactory = opts.socketFactory;
+ }
+ socket = createSurfaceSocket(socketOpts);
+
+ // Fetch model catalog
+ void fetchImpl(`${httpBase}/models`)
+ .then((res) => {
+ if (!res.ok) return;
+ return res.json() as Promise<ModelsResponse>;
+ })
+ .then((data) => {
+ if (data === undefined) return;
+ models = data.models;
+ modelInfo = data.modelInfo ?? {};
+ if (data.models.length > 0 && !data.models.includes(activeModel)) {
+ const first = data.models[0];
+ if (first !== undefined) {
+ activeModel = first;
+ draftStore.setModel(first);
+ }
+ }
+ })
+ .catch((err) => {
+ reportError("Failed to load model list", err);
+ });
+
+ // Fetch the discovered-computer catalog (global, like models). Empty until
+ // the `ssh` extension lands — a safe no-op until then (the selector shows
+ // "Local (none)" only). Non-fatal: a failure leaves an empty list.
+ void fetchImpl(`${httpBase}/computers`)
+ .then((res) => {
+ if (!res.ok) return { computers: [] } as ComputerListResponse;
+ return res.json() as Promise<ComputerListResponse>;
+ })
+ .then((data) => {
+ computers = data?.computers ?? [];
+ })
+ .catch((err) => {
+ reportError("Failed to load computer list", err);
+ });
+
+ // Restore persisted tabs
+ const persistedState = storageAdapter.load();
+ if (persistedState !== null && persistedState.tabs.length > 0) {
+ for (const tab of persistedState.tabs) {
+ const store = createChatFor(tab.conversationId, tab.model, tab.workspaceId);
+ chatStores.set(tab.conversationId, store);
+ void store.load();
+ // Watch each restored conversation's live turns: after a reload mid-turn the
+ // server replays the in-flight turn so we keep rendering it. Queued until the
+ // socket opens.
+ subscribeChat(tab.conversationId);
+ }
+ if (persistedState.activeConversationId !== null) {
+ const activeTab = persistedState.tabs.find(
+ (t) => t.conversationId === persistedState.activeConversationId,
+ );
+ if (activeTab !== undefined) {
+ activeModel = activeTab.model;
+ }
+ }
+ }
+
+ refreshActiveChat();
+ void refreshCwd();
+ void refreshComputer();
+ void refreshModel();
+ void refreshReasoningEffort();
+ void refreshCompactPercent();
+
+ // Fetch the authoritative open-conversation list from the backend (cross-
+ // device tab sync). Merges with the localStorage-restored tabs: opens new
+ // ones, removes closed ones, updates titles + statuses.
+ void fetchOpenConversations();
+
+ return {
+ get tabs(): readonly Tab[] {
+ return tabsStore.tabs.filter((t) => t.workspaceId === activeWorkspaceId);
+ },
+ get activeConversationId(): string | null {
+ return tabsStore.activeConversationId;
+ },
+ get activeWorkspaceId(): string {
+ return activeWorkspaceId;
+ },
+ setActiveWorkspace(workspaceId: string): void {
+ activeWorkspaceId = workspaceId;
+ // Reset to a fresh draft scoped to the new workspace so a new chat is
+ // stamped with the right `workspaceId` on `chat.send`.
+ const nextDraftId = randomId();
+ draftStore = createChatFor(nextDraftId, activeModel, workspaceId);
+ draftConversationId = nextDraftId;
+ tabsStore.newDraft();
+ refreshActiveChat();
+ syncSubscriptions();
+ void refreshCwd();
+ void refreshComputer();
+ void refreshModel();
+ void refreshReasoningEffort();
+ void refreshCompactPercent();
+ },
+ get activeChat(): ChatStore {
+ return activeChat;
+ },
+ get models(): readonly string[] {
+ return models;
+ },
+ get modelInfo(): Readonly<Record<string, ModelMetadata>> {
+ return modelInfo;
+ },
+ get activeModel(): string {
+ return activeModel;
+ },
+ get catalog() {
+ return protocol.catalog;
+ },
+ get surfaces(): readonly SurfaceSpec[] {
+ const out: SurfaceSpec[] = [];
+ for (const entry of protocol.catalog) {
+ const spec = getSurfaceSpec(protocol, entry.id);
+ if (spec) out.push(spec);
+ }
+ return out;
+ },
+ get lastError() {
+ return protocol.lastError;
+ },
+ get storage() {
+ return localStorageOpt;
+ },
+ get cwd(): string | null {
+ return cwd;
+ },
+ get computerId(): string | null {
+ return computerId;
+ },
+ get computers(): readonly ComputerEntry[] {
+ return computers;
+ },
+ get reasoningEffort(): ReasoningEffort | null {
+ return reasoningEffort;
+ },
+ get compactPercent(): number | null {
+ return compactPercent;
+ },
+ get chatLimit(): number {
+ return chatLimit;
+ },
+ conversationStatus(conversationId: string): ConversationStatus | undefined {
+ return conversationStatuses.get(conversationId);
+ },
+ get currentConversationId(): string {
+ return workspaceConversationId();
+ },
+
+ surface(surfaceId: string): SurfaceSpec | null {
+ return getSurfaceSpec(protocol, surfaceId);
+ },
+
+ send(text: string): void {
+ if (tabsStore.activeConversationId === null) {
+ // Draft: promote to tab on first send
+ const conversationId = draftConversationId;
+ const model = activeModel;
+ tabsStore.createTab({
+ conversationId,
+ model,
+ title: deriveTitle(text),
+ workspaceId: activeWorkspaceId,
+ });
+ chatStores.set(conversationId, draftStore);
+ void draftStore.load();
+
+ // Prepare next draft
+ const nextDraftId = randomId();
+ draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId);
+ draftConversationId = nextDraftId;
+
+ refreshActiveChat();
+ // The draft became a real conversation: re-scope conversation-scoped
+ // surfaces (e.g. cache-warming) to its id.
+ syncSubscriptions();
+ void refreshCwd();
+ void refreshComputer();
+ void refreshReasoningEffort();
+ void refreshCompactPercent();
+ // Now send on the promoted store
+ chatStores.get(conversationId)?.send(text);
+ } else {
+ activeChat.send(text);
+ }
+ },
+
+ queueMessage(text: string): void {
+ // Only offered while generating (Composer switches to `chat.queue`
+ // when `status === "running"`), so a draft (never generating) never
+ // reaches here. `chat.queue` auto-starts a turn if idle, so even a race
+ // (turn sealed between the status read and the send) is safe — the
+ // server starts a fresh turn with the message as its opening prompt.
+ activeChat.queueMessage(text);
+ },
+
+ selectModel(model: string): void {
+ activeModel = model;
+ const activeId = tabsStore.activeConversationId;
+ if (activeId !== null) {
+ tabsStore.setModel(activeId, model);
+ chatStores.get(activeId)?.setModel(model);
+ void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(activeId)}/model`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model } satisfies SetModelRequest),
+ }).catch((err) => {
+ reportError("Failed to persist model", err);
+ });
+ } else {
+ draftStore.setModel(model);
+ }
+ },
+
+ newDraft(): void {
+ tabsStore.newDraft();
+ const nextDraftId = randomId();
+ draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId);
+ draftConversationId = nextDraftId;
+ refreshActiveChat();
+ syncSubscriptions();
+ void refreshCwd();
+ void refreshComputer();
+ void refreshModel();
+ void refreshReasoningEffort();
+ void refreshCompactPercent();
+ },
+
+ selectTab(conversationId: string): void {
+ tabsStore.selectTab(conversationId);
+ const tab = tabsStore.tabs.find((t) => t.conversationId === conversationId);
+ if (tab !== undefined) {
+ activeModel = tab.model;
+ }
+ refreshActiveChat();
+ syncSubscriptions();
+ void refreshCwd();
+ void refreshComputer();
+ void refreshModel();
+ void refreshReasoningEffort();
+ void refreshCompactPercent();
+ },
+
+ closeTab(conversationId: string): void {
+ // The user is DONE with this chat: abort any in-flight turn + stop/disable
+ // its cache-warming, server-side (POST /close sets status → "closed").
+ closeConversation(conversationId);
+ removeTabLocally(conversationId);
+ },
+
+ renameTab(conversationId: string, title: string): void {
+ tabsStore.setTitle(conversationId, title);
+ void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/title`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title } satisfies SetTitleRequest),
+ }).catch((err) => {
+ reportError("Failed to rename conversation", err);
+ });
+ },
+
+ invoke(surfaceId: string, actionId: string, payload?: unknown): void {
+ const result = protocolInvoke(
+ protocol,
+ surfaceId,
+ actionId,
+ payload,
+ focusedConversationId(),
+ );
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ socket?.send(msg);
+ }
+ },
+
+ async warmNow(): Promise<WarmResult | null> {
+ const conversationId = tabsStore.activeConversationId;
+ if (conversationId === null) return null;
+ const body: WarmRequest = { conversationId, model: activeModel };
+ try {
+ const res = await fetchImpl(`${httpBase}/chat/warm`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return { ok: false, error: errBody?.error ?? `Warm failed (HTTP ${res.status})` };
+ }
+ return { ok: true, response: (await res.json()) as WarmResponse };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Warm request failed" };
+ }
+ },
+
+ async setCwd(value: string): Promise<CwdResult | null> {
+ const id = workspaceConversationId();
+ const body: SetCwdRequest = {
+ cwd: value,
+ workspaceId: untrack(() => activeWorkspaceId),
+ };
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return { ok: false, error: errBody?.error ?? `Set cwd failed (HTTP ${res.status})` };
+ }
+ const data = (await res.json()) as CwdResponse;
+ const next = data.cwd ?? null;
+ if (workspaceConversationId() === id) cwd = next;
+ return { ok: true, cwd: next };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Set cwd request failed" };
+ }
+ },
+
+ async setComputer(computerIdValue: string | null): Promise<ComputerResult | null> {
+ const id = workspaceConversationId();
+ const body: SetConversationComputerRequest = { computerId: computerIdValue };
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/computer`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set computer failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as ConversationComputerResponse;
+ const next = data.computerId ?? null;
+ if (workspaceConversationId() === id) computerId = next;
+ return { ok: true, computerId: next };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set computer request failed",
+ };
+ }
+ },
+
+ async computerStatus(alias: string): Promise<ComputerStatusResult | null> {
+ if (alias === "") return null;
+ try {
+ const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/status`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Computer status failed (HTTP ${res.status})`,
+ };
+ }
+ const status = (await res.json()) as ComputerStatusResponse;
+ return { ok: true, response: status };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Computer status request failed",
+ };
+ }
+ },
+
+ async testComputer(alias: string): Promise<TestComputerResult | null> {
+ if (alias === "") return null;
+ try {
+ const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/test`, {
+ method: "POST",
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Test computer failed (HTTP ${res.status})`,
+ };
+ }
+ const response = (await res.json()) as TestComputerResponse;
+ return { ok: true, response };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Test computer request failed",
+ };
+ }
+ },
+
+ async setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null> {
+ const id = workspaceConversationId();
+ const body: SetReasoningEffortRequest = { reasoningEffort: level };
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set reasoning effort failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as ReasoningEffortResponse;
+ const next = data.reasoningEffort ?? level;
+ if (workspaceConversationId() === id) reasoningEffort = next;
+ return { ok: true, reasoningEffort: next };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set reasoning effort request failed",
+ };
+ }
+ },
+
+ stopGeneration(): void {
+ const conversationId = tabsStore.activeConversationId;
+ if (conversationId === null) return;
+ void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/stop`, {
+ method: "POST",
+ }).catch((err) => {
+ reportError("Failed to stop generation", err);
+ });
+ },
+
+ async compactNow(keepLastN?: number): Promise<CompactResult | null> {
+ const conversationId = tabsStore.activeConversationId;
+ if (conversationId === null) return null;
+ const body: Record<string, unknown> = {};
+ if (keepLastN !== undefined) body.keepLastN = keepLastN;
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(conversationId)}/compact`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Compact failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as CompactResponse;
+ return { ok: true, response: data };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Compact request failed",
+ };
+ }
+ },
+
+ async setCompactPercent(percent: number): Promise<CompactPercentResult | null> {
+ const id = workspaceConversationId();
+ const body: SetCompactPercentRequest = { threshold: percent };
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set compact percent failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as CompactPercentResponse;
+ if (workspaceConversationId() === id) compactPercent = data.threshold;
+ return { ok: true, percent: data.threshold };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set compact percent request failed",
+ };
+ }
+ },
+
+ async setChatLimit(limit: number): Promise<ChatLimitResult> {
+ const next = normalizeChatLimit(limit);
+ chatLimitStore.save(next);
+ chatLimit = next;
+ // Propagate to every live chat store. The ACTIVE one is awaited so its
+ // refill (on a raise) lands before the caller returns — letting the
+ // shell preserve scroll over the prepended older chunks. Background
+ // stores refill fire-and-forget. Future stores pick the new limit up at
+ // creation (via the persisted store).
+ const active = getActiveChat();
+ await active.setChatLimit(next);
+ for (const s of chatStores.values()) {
+ if (s !== active) void s.setChatLimit(next);
+ }
+ if (draftStore !== active) void draftStore.setChatLimit(next);
+ return { ok: true, chatLimit: next };
+ },
+
+ async lspStatus(): Promise<LspResult | null> {
+ const id = workspaceConversationId();
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/lsp`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return { ok: false, error: errBody?.error ?? `LSP status failed (HTTP ${res.status})` };
+ }
+ // Normalize the untyped body at this network seam so a malformed/partial
+ // response can never crash the renderer (servers is guaranteed an array).
+ const data = (await res.json()) as Partial<LspStatusResponse>;
+ const response: LspStatusResponse = {
+ conversationId: data.conversationId ?? id,
+ cwd: data.cwd ?? null,
+ servers: Array.isArray(data.servers) ? data.servers : [],
+ };
+ return { ok: true, response };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "LSP status request failed",
+ };
+ }
+ },
+
+ async mcpStatus(): Promise<McpResult | null> {
+ const id = workspaceConversationId();
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/mcp`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return { ok: false, error: errBody?.error ?? `MCP status failed (HTTP ${res.status})` };
+ }
+ // Normalize the untyped body at this network seam so a malformed/partial
+ // response can never crash the renderer (servers is guaranteed an array).
+ const data = (await res.json()) as Partial<McpStatusResponse>;
+ const response: McpStatusResponse = {
+ conversationId: data.conversationId ?? id,
+ cwd: data.cwd ?? null,
+ servers: Array.isArray(data.servers) ? data.servers : [],
+ };
+ return { ok: true, response };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "MCP status request failed",
+ };
+ }
+ },
+
+ async heartbeatConfig(): Promise<HeartbeatConfigResult> {
+ // Workspace-scoped (NOT per-conversation): use the active workspace id.
+ const wsId = untrack(() => activeWorkspaceId);
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Heartbeat config failed (HTTP ${res.status})`,
+ };
+ }
+ // Normalize the untyped JSON at the network seam (pure helper) so a
+ // malformed/partial response can never crash the renderer.
+ const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json());
+ return { ok: true, config };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Heartbeat config request failed",
+ };
+ }
+ },
+
+ async setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult> {
+ const wsId = untrack(() => activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(patch),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set heartbeat config failed (HTTP ${res.status})`,
+ };
+ }
+ const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json());
+ return { ok: true, config };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set heartbeat config request failed",
+ };
+ }
+ },
+
+ async heartbeatRuns(): Promise<HeartbeatRunsResult> {
+ const wsId = untrack(() => activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs`,
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Heartbeat runs failed (HTTP ${res.status})`,
+ };
+ }
+ const runs: readonly HeartbeatRun[] = normalizeHeartbeatRuns(await res.json());
+ return { ok: true, runs };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Heartbeat runs request failed",
+ };
+ }
+ },
+
+ async stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> {
+ const wsId = untrack(() => activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs/${encodeURIComponent(runId)}/stop`,
+ { method: "POST" },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Stop heartbeat run failed (HTTP ${res.status})`,
+ };
+ }
+ return { ok: true };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Stop heartbeat run request failed",
+ };
+ }
+ },
+
+ async heartbeatNextRun(): Promise<HeartbeatNextRunResult> {
+ const wsId = untrack(() => activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/next-run`,
+ );
+ if (!res.ok) {
+ // 404 = the backend hasn't shipped CR-HB-3 yet → the FE falls back to
+ // an approximation. Surface as ok:false (non-fatal).
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Heartbeat next-run failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json().catch(() => null)) as { nextRunAt?: string | null } | null;
+ // `null` (disabled / no run scheduled) passes through; anything non-string
+ // also becomes null so a malformed body can't crash the countdown.
+ const raw = data?.nextRunAt;
+ const nextRunAt = typeof raw === "string" ? raw : null;
+ return { ok: true, nextRunAt };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Heartbeat next-run request failed",
+ };
+ }
+ },
+
+ watchConversation(conversationId: string): ChatStore {
+ return watchConversation(conversationId);
+ },
+
+ unwatchConversation(conversationId: string): void {
+ unwatchConversation(conversationId);
+ },
+
+ async loadSystemPrompt(): Promise<SystemPromptLoadResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/system-prompt`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Load system prompt failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as SystemPromptTemplateResponse;
+ return { ok: true, template: data.template ?? "" };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Load system prompt request failed",
+ };
+ }
+ },
+
+ async setSystemPrompt(template: string): Promise<SystemPromptSaveResult> {
+ try {
+ const body: SetSystemPromptTemplateRequest = { template };
+ const res = await fetchImpl(`${httpBase}/system-prompt`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set system prompt failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as SystemPromptTemplateResponse;
+ return { ok: true, template: data.template };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set system prompt request failed",
+ };
+ }
+ },
+
+ async loadSystemPromptVariables(): Promise<SystemPromptVariablesResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/system-prompt/variables`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Load system prompt variables failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as Partial<SystemPromptVariablesResponse>;
+ return { ok: true, variables: Array.isArray(data.variables) ? data.variables : [] };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Load system prompt variables request failed",
+ };
+ }
+ },
+
+ attachUnloadGate(gate: () => boolean): void {
+ unloadGate = gate;
+ },
+
+ get fatalError(): string | null {
+ return fatalError;
+ },
+ clearFatalError(): void {
+ fatalError = null;
+ },
+
+ dispose(): void {
+ for (const store of chatStores.values()) {
+ store.dispose();
+ }
+ chatStores.clear();
+ for (const store of watchStores.values()) {
+ store.dispose();
+ }
+ watchStores.clear();
+ draftStore.dispose();
+ socket?.close();
+ socket = null;
+ },
+ };
}
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index c428769..947a9b0 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -5,50 +5,50 @@ import type { WebSocketLike } from "../adapters/ws";
import { createAppStore } from "./store.svelte";
interface FakeSocket extends WebSocketLike {
- sent: string[];
- resolveOpen(): void;
- feedServerMessage(data: WsServerMessage): void;
- feedSurfaceMessage(data: SurfaceServerMessage): void;
+ sent: string[];
+ resolveOpen(): void;
+ feedServerMessage(data: WsServerMessage): void;
+ feedSurfaceMessage(data: SurfaceServerMessage): void;
}
function fakeSocket(): FakeSocket {
- let onopen: (() => void) | null = null;
- let onmessage: ((ev: { data: string }) => void) | null = null;
- const sent: string[] = [];
-
- const ws: FakeSocket = {
- send(data: string) {
- sent.push(data);
- },
- close() {},
- get onopen() {
- return onopen;
- },
- set onopen(fn) {
- onopen = fn;
- },
- get onmessage() {
- return onmessage;
- },
- set onmessage(fn) {
- onmessage = fn;
- },
- get onclose() {
- return null;
- },
- set onclose(_fn) {},
- resolveOpen() {
- onopen?.();
- },
- feedServerMessage(msg: WsServerMessage) {
- onmessage?.({ data: JSON.stringify(msg) });
- },
- feedSurfaceMessage(msg: SurfaceServerMessage) {
- onmessage?.({ data: JSON.stringify(msg) });
- },
- sent,
- };
- return ws;
+ let onopen: (() => void) | null = null;
+ let onmessage: ((ev: { data: string }) => void) | null = null;
+ const sent: string[] = [];
+
+ const ws: FakeSocket = {
+ send(data: string) {
+ sent.push(data);
+ },
+ close() {},
+ get onopen() {
+ return onopen;
+ },
+ set onopen(fn) {
+ onopen = fn;
+ },
+ get onmessage() {
+ return onmessage;
+ },
+ set onmessage(fn) {
+ onmessage = fn;
+ },
+ get onclose() {
+ return null;
+ },
+ set onclose(_fn) {},
+ resolveOpen() {
+ onopen?.();
+ },
+ feedServerMessage(msg: WsServerMessage) {
+ onmessage?.({ data: JSON.stringify(msg) });
+ },
+ feedSurfaceMessage(msg: SurfaceServerMessage) {
+ onmessage?.({ data: JSON.stringify(msg) });
+ },
+ sent,
+ };
+ return ws;
}
/**
@@ -57,1322 +57,1322 @@ function fakeSocket(): FakeSocket {
* `sent` accumulates and `open()` can be driven again after `closeRemote()`.
*/
interface ReconnectableSocket extends WebSocketLike {
- sent: string[];
- open(): void;
- closeRemote(): void;
+ sent: string[];
+ open(): void;
+ closeRemote(): void;
}
function reconnectableSocket(): ReconnectableSocket {
- let onopen: (() => void) | null = null;
- let onmessage: ((ev: { data: string }) => void) | null = null;
- let onclose: ((ev: { code: number; reason: string }) => void) | null = null;
- const sent: string[] = [];
- return {
- send(data: string) {
- sent.push(data);
- },
- close() {},
- get onopen() {
- return onopen;
- },
- set onopen(fn) {
- onopen = fn;
- },
- get onmessage() {
- return onmessage;
- },
- set onmessage(fn) {
- onmessage = fn;
- },
- get onclose() {
- return onclose;
- },
- set onclose(fn) {
- onclose = fn;
- },
- sent,
- open() {
- onopen?.();
- },
- closeRemote() {
- onclose?.({ code: 1006, reason: "" });
- },
- };
+ let onopen: (() => void) | null = null;
+ let onmessage: ((ev: { data: string }) => void) | null = null;
+ let onclose: ((ev: { code: number; reason: string }) => void) | null = null;
+ const sent: string[] = [];
+ return {
+ send(data: string) {
+ sent.push(data);
+ },
+ close() {},
+ get onopen() {
+ return onopen;
+ },
+ set onopen(fn) {
+ onopen = fn;
+ },
+ get onmessage() {
+ return onmessage;
+ },
+ set onmessage(fn) {
+ onmessage = fn;
+ },
+ get onclose() {
+ return onclose;
+ },
+ set onclose(fn) {
+ onclose = fn;
+ },
+ sent,
+ open() {
+ onopen?.();
+ },
+ closeRemote() {
+ onclose?.({ code: 1006, reason: "" });
+ },
+ };
}
interface FakeFetchOptions {
- models?: readonly string[];
- history?: Record<string, ConversationHistoryResponse>;
- model?: string | null;
+ models?: readonly string[];
+ history?: Record<string, ConversationHistoryResponse>;
+ model?: string | null;
}
function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
- const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"];
- const history = opts?.history ?? {};
- return async (input: string | URL | Request): Promise<Response> => {
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models }), { status: 200 });
- }
- if (url.endsWith("/model")) {
- return new Response(
- JSON.stringify({
- conversationId: "ignored",
- model: opts?.model ?? null,
- }),
- { status: 200 },
- );
- }
- const body =
- history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse);
- return new Response(JSON.stringify(body), { status: 200 });
- };
+ const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"];
+ const history = opts?.history ?? {};
+ return async (input: string | URL | Request): Promise<Response> => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/models")) {
+ return new Response(JSON.stringify({ models }), { status: 200 });
+ }
+ if (url.endsWith("/model")) {
+ return new Response(
+ JSON.stringify({
+ conversationId: "ignored",
+ model: opts?.model ?? null,
+ }),
+ { status: 200 },
+ );
+ }
+ const body =
+ history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse);
+ return new Response(JSON.stringify(body), { status: 200 });
+ };
}
function parseSent(ws: { sent: string[] }): unknown[] {
- return ws.sent.map((s) => JSON.parse(s));
+ return ws.sent.map((s) => JSON.parse(s));
}
function createFakeStorage(): Storage {
- const map = new Map<string, string>();
- return {
- get length() {
- return map.size;
- },
- clear() {
- map.clear();
- },
- getItem(key: string): string | null {
- return map.get(key) ?? null;
- },
- key(_index: number): string | null {
- return null;
- },
- removeItem(key: string) {
- map.delete(key);
- },
- setItem(key: string, value: string) {
- map.set(key, value);
- },
- };
+ const map = new Map<string, string>();
+ return {
+ get length() {
+ return map.size;
+ },
+ clear() {
+ map.clear();
+ },
+ getItem(key: string): string | null {
+ return map.get(key) ?? null;
+ },
+ key(_index: number): string | null {
+ return null;
+ },
+ removeItem(key: string) {
+ map.delete(key);
+ },
+ setItem(key: string, value: string) {
+ map.set(key, value);
+ },
+ };
}
function activeConversationId(store: ReturnType<typeof createAppStore>): string {
- const id = store.activeConversationId;
- expect(id).not.toBeNull();
- return id as string;
+ const id = store.activeConversationId;
+ expect(id).not.toBeNull();
+ return id as string;
}
describe("createAppStore", () => {
- it("starts with empty catalog and no surfaces", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- expect(store.catalog).toEqual([]);
- expect(store.surfaces).toEqual([]);
- expect(store.lastError).toBeNull();
-
- store.dispose();
- });
-
- it("updates catalog when catalog message arrives", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [
- { id: "s1", region: "sidebar", title: "Surface One" },
- { id: "s2", region: "panel", title: "Surface Two" },
- ],
- });
-
- expect(store.catalog).toHaveLength(2);
- expect(store.catalog[0]?.id).toBe("s1");
- expect(store.catalog[1]?.id).toBe("s2");
-
- store.dispose();
- });
-
- it("auto-subscribes to every catalog entry when the catalog arrives", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.sent.length = 0;
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [
- { id: "s1", region: "sidebar", title: "Surface One" },
- { id: "s2", region: "panel", title: "Surface Two" },
- ],
- });
-
- const subscribed = ws.sent
- .map((s) => JSON.parse(s))
- .filter((p) => p.type === "subscribe")
- .map((p) => p.surfaceId);
- expect(subscribed).toContain("s1");
- expect(subscribed).toContain("s2");
-
- store.dispose();
- });
-
- it("unsubscribes from entries that vanish from a new catalog", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [
- { id: "s1", region: "sidebar", title: "Surface One" },
- { id: "s2", region: "panel", title: "Surface Two" },
- ],
- });
-
- ws.sent.length = 0;
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
- });
-
- const unsubscribed = ws.sent
- .map((s) => JSON.parse(s))
- .filter((p) => p.type === "unsubscribe")
- .map((p) => p.surfaceId);
- expect(unsubscribed).toContain("s2");
- expect(unsubscribed).not.toContain("s1");
-
- store.dispose();
- });
-
- it("exposes received surface specs via `surfaces`, in catalog order", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [
- { id: "s1", region: "sidebar", title: "Surface One" },
- { id: "s2", region: "panel", title: "Surface Two" },
- ],
- });
-
- // Only s1's spec has arrived: surfaces reflects what's actually received.
- ws.feedSurfaceMessage({
- type: "surface",
- spec: {
- id: "s1",
- region: "sidebar",
- title: "Surface One",
- fields: [{ kind: "stat", label: "Tokens", value: "1,234" }],
- },
- });
- expect(store.surfaces.map((s) => s.id)).toEqual(["s1"]);
-
- ws.feedSurfaceMessage({
- type: "surface",
- spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] },
- });
- // Catalog order preserved (s1 before s2).
- expect(store.surfaces.map((s) => s.id)).toEqual(["s1", "s2"]);
- expect(store.surfaces[0]?.fields).toHaveLength(1);
-
- store.dispose();
- });
-
- it("invoke sends an invoke message", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.sent.length = 0;
- store.invoke("s1", "toggle-dark", true);
-
- const invokeMsg = ws.sent.find((s) => {
- const parsed = JSON.parse(s);
- return (
- parsed.type === "invoke" &&
- parsed.surfaceId === "s1" &&
- parsed.actionId === "toggle-dark" &&
- parsed.payload === true
- );
- });
- expect(invokeMsg).toBeTruthy();
-
- store.dispose();
- });
-
- it("error message updates lastError", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "error",
- message: "Something went wrong",
- });
-
- expect(store.lastError).not.toBeNull();
- expect(store.lastError?.message).toBe("Something went wrong");
-
- store.dispose();
- });
-
- it("dispose closes the socket", () => {
- const ws = fakeSocket();
- const closeSpy = { called: false };
- const origClose = ws.close.bind(ws);
- ws.close = () => {
- closeSpy.called = true;
- origClose();
- };
-
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.dispose();
- expect(closeSpy.called).toBe(true);
- });
-
- it("exposes activeChat with empty initial messages", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- expect(store.activeChat).toBeDefined();
- expect(store.activeChat.messages).toEqual([]);
- expect(store.activeChat.chunks).toEqual([]);
- expect(store.activeChat.error).toBeNull();
-
- store.dispose();
- });
-
- it("sending a message from draft creates a tab and posts chat.send", () => {
- const ws = fakeSocket();
- const storage = createFakeStorage();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: storage,
- });
- ws.resolveOpen();
-
- ws.sent.length = 0;
- store.send("hello world");
-
- expect(store.tabs).toHaveLength(1);
- expect(store.tabs[0]?.title).toBe("hello world");
- expect(store.activeConversationId).not.toBeNull();
-
- const msgs = parseSent(ws);
- const chatSend = msgs.find((m) => (m as { type: string }).type === "chat.send") as
- | { type: string; conversationId: string; message: string }
- | undefined;
- expect(chatSend).toBeTruthy();
- expect(chatSend?.message).toBe("hello world");
-
- store.dispose();
- });
-
- it("an incoming chat.delta renders in the transcript", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("test");
- const convId = activeConversationId(store);
-
- ws.feedServerMessage({
- type: "chat.delta",
- event: { type: "turn-start", conversationId: convId, turnId: "turn-1" },
- });
-
- ws.feedServerMessage({
- type: "chat.delta",
- event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "Hello " },
- });
-
- ws.feedServerMessage({
- type: "chat.delta",
- event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "world" },
- });
-
- expect(store.activeChat.chunks.length).toBeGreaterThan(0);
- const assistantChunks = store.activeChat.chunks.filter(
- (c) => c.role === "assistant" && c.chunk.type === "text",
- );
- expect(assistantChunks).toHaveLength(1);
- expect((assistantChunks[0]?.chunk as { type: "text"; text: string }).text).toBe("Hello world");
-
- store.dispose();
- });
-
- it("chat.error sets the chat error", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("test");
- const convId = activeConversationId(store);
-
- ws.feedServerMessage({
- type: "chat.error",
- conversationId: convId,
- message: "bad request",
- });
-
- expect(store.activeChat.error).toBe("bad request");
-
- store.dispose();
- });
-
- it("turn-sealed triggers a history fetch and synced chunks render", async () => {
- const fetchedUrls: string[] = [];
- const historyResponse: ConversationHistoryResponse = {
- chunks: [
- { seq: 1, role: "user", chunk: { type: "text", text: "hi" } },
- { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } },
- ],
- latestSeq: 2,
- };
- const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- fetchedUrls.push(url);
- if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
- status: 200,
- });
- }
- return new Response(JSON.stringify(historyResponse), { status: 200 });
- };
-
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl,
- httpUrl: "http://localhost:24203",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("hi");
- const convId = activeConversationId(store);
-
- ws.feedServerMessage({
- type: "chat.delta",
- event: { type: "turn-start", conversationId: convId, turnId: "turn-1" },
- });
-
- ws.feedServerMessage({
- type: "chat.delta",
- event: { type: "turn-sealed", conversationId: convId, turnId: "turn-1" },
- });
-
- await new Promise((r) => setTimeout(r, 50));
-
- expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true);
-
- await new Promise((r) => setTimeout(r, 50));
-
- expect(store.activeChat.chunks.length).toBeGreaterThan(0);
-
- store.dispose();
- });
-
- it("fetches and exposes the model catalog", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl({
- models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"],
- }),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- await new Promise((r) => setTimeout(r, 50));
-
- expect(store.models).toEqual([
- "opencode/deepseek-v4-flash",
- "openai/gpt-4o",
- "anthropic/claude-3",
- ]);
-
- store.dispose();
- });
-
- it("default model is flash", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- expect(store.activeModel).toBe("opencode/deepseek-v4-flash");
-
- store.dispose();
- });
-
- it("draft: sending the first message creates a tab titled from the message", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- expect(store.tabs).toHaveLength(0);
- expect(store.activeConversationId).toBeNull();
-
- store.send("What is the meaning of life?");
-
- expect(store.tabs).toHaveLength(1);
- expect(store.tabs[0]?.title).toBe("What is the meaning of life?");
- expect(store.activeConversationId).toBe(store.tabs[0]?.conversationId);
-
- store.dispose();
- });
-
- it("selecting a model persists it to the backend", () => {
- const ws = fakeSocket();
- const fetchImpl = fakeFetchImpl();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl,
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("hello");
- store.selectModel("openai/gpt-4o");
-
- const put = vi.fn().mockResolvedValue(
- new Response(JSON.stringify({ conversationId: "ignored", model: "openai/gpt-4o" }), {
- status: 200,
- }),
- );
- const capturingStore = createAppStore({
- socketFactory: () => fakeSocket(),
- fetchImpl: async (input, init) => {
- const url =
- typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (init?.method === "PUT" && url.endsWith("/model")) {
- put(url, init);
- }
- return fetchImpl(input, init);
- },
- localStorage: createFakeStorage(),
- });
- capturingStore.send("hello");
- capturingStore.selectModel("openai/gpt-4o");
-
- expect(put).toHaveBeenCalledOnce();
- const [callUrl, callInit] = put.mock.calls[0] as [string, RequestInit];
- expect(callUrl.endsWith("/model")).toBe(true);
- expect(JSON.parse(callInit.body as string)).toEqual({ model: "openai/gpt-4o" });
-
- store.dispose();
- capturingStore.dispose();
- });
-
- it("focuses a conversation with a persisted model", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl({ model: "openai/gpt-4o" }),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("first message");
-
- // New tab opens with the default model until the persisted-model fetch resolves.
- expect(store.activeModel).toBe("opencode/deepseek-v4-flash");
-
- // Wait for the persisted model fetch to resolve.
- await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o"));
- expect(store.tabs[0]?.model).toBe("openai/gpt-4o");
-
- store.dispose();
- });
-
- it("chat.delta routes to the matching tab only", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("first message");
- const convId1 = activeConversationId(store);
-
- store.newDraft();
- store.send("second message");
- const convId2 = activeConversationId(store);
-
- expect(convId1).not.toBe(convId2);
-
- ws.feedServerMessage({
- type: "chat.delta",
- event: { type: "turn-start", conversationId: convId1, turnId: "turn-1" },
- });
- ws.feedServerMessage({
- type: "chat.delta",
- event: {
- type: "text-delta",
- conversationId: convId1,
- turnId: "turn-1",
- delta: "response to first",
- },
- });
-
- store.selectTab(convId1);
- const assistantChunks1 = store.activeChat.chunks.filter(
- (c) => c.role === "assistant" && c.chunk.type === "text",
- );
- expect(assistantChunks1).toHaveLength(1);
- expect((assistantChunks1[0]?.chunk as { type: "text"; text: string }).text).toBe(
- "response to first",
- );
-
- store.selectTab(convId2);
- const assistantChunks2 = store.activeChat.chunks.filter(
- (c) => c.role === "assistant" && c.chunk.type === "text",
- );
- expect(assistantChunks2).toEqual([]);
-
- store.dispose();
- });
-
- it("closing a tab evicts its cache and drops the tab", () => {
- const ws = fakeSocket();
- const storage = createFakeStorage();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: storage,
- });
- ws.resolveOpen();
-
- store.send("first");
- const convId = activeConversationId(store);
- expect(store.tabs).toHaveLength(1);
-
- store.closeTab(convId);
-
- expect(store.tabs).toHaveLength(0);
- expect(store.activeConversationId).toBeNull();
-
- store.dispose();
- });
-
- it("closing a tab POSTs /conversations/:id/close (abort turn + stop warming)", async () => {
- const calls: { url: string; method: string }[] = [];
- const base = fakeFetchImpl();
- const fetchImpl: typeof fetch = async (input, init) => {
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- calls.push({ url, method: init?.method ?? "GET" });
- if (url.endsWith("/close")) {
- return new Response(
- JSON.stringify({ conversationId: url.split("/").at(-2), abortedTurn: false }),
- { status: 200 },
- );
- }
- return base(input, init);
- };
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl,
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("first");
- const convId = activeConversationId(store);
- store.closeTab(convId);
- await Promise.resolve(); // flush the fire-and-forget fetch
-
- const close = calls.find((c) => c.url.endsWith(`/conversations/${convId}/close`));
- expect(close).toBeDefined();
- expect(close?.method).toBe("POST");
-
- store.dispose();
- });
-
- it("seeds reasoningEffort from GET /conversations/:id/reasoning-effort (null = never set)", async () => {
- const base = fakeFetchImpl();
- const fetchImpl: typeof fetch = async (input, init) => {
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/reasoning-effort")) {
- return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: "xhigh" }), {
- status: 200,
- });
- }
- return base(input, init);
- };
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl,
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- await vi.waitFor(() => {
- expect(store.reasoningEffort).toBe("xhigh");
- });
-
- store.dispose();
- });
-
- it("setReasoningEffort PUTs the level and updates local state from the echo", async () => {
- const calls: { url: string; method: string; body: string | undefined }[] = [];
- const base = fakeFetchImpl();
- const fetchImpl: typeof fetch = async (input, init) => {
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- calls.push({ url, method: init?.method ?? "GET", body: init?.body as string | undefined });
- if (url.endsWith("/reasoning-effort") && init?.method === "PUT") {
- const sent = JSON.parse(init.body as string) as { reasoningEffort: string };
- return new Response(
- JSON.stringify({ conversationId: "x", reasoningEffort: sent.reasoningEffort }),
- { status: 200 },
- );
- }
- if (url.endsWith("/reasoning-effort")) {
- return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), {
- status: 200,
- });
- }
- return base(input, init);
- };
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl,
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- const result = await store.setReasoningEffort("max");
- expect(result).toEqual({ ok: true, reasoningEffort: "max" });
- expect(store.reasoningEffort).toBe("max");
-
- const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/reasoning-effort"));
- expect(put).toBeDefined();
- // The PUT targets the workspace conversation (draft id works too) and
- // carries exactly the SetReasoningEffortRequest body.
- expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`);
- expect(JSON.parse(put?.body ?? "{}")).toEqual({ reasoningEffort: "max" });
-
- store.dispose();
- });
-
- it("setReasoningEffort surfaces a 400 error and leaves state unchanged", async () => {
- const base = fakeFetchImpl();
- const fetchImpl: typeof fetch = async (input, init) => {
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/reasoning-effort") && init?.method === "PUT") {
- return new Response(JSON.stringify({ error: "bad level" }), { status: 400 });
- }
- if (url.endsWith("/reasoning-effort")) {
- return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), {
- status: 200,
- });
- }
- return base(input, init);
- };
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl,
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- const result = await store.setReasoningEffort("max");
- expect(result).toEqual({ ok: false, error: "bad level" });
- expect(store.reasoningEffort).toBeNull();
-
- store.dispose();
- });
-
- it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [
- { id: "s-global", region: "side", title: "Global", scope: "global" },
- { id: "s-conv", region: "side", title: "Scoped", scope: "conversation" },
- ],
- });
-
- ws.sent.length = 0;
- store.send("promote the draft"); // draft → real conversation: surfaces re-scope
- const convId = activeConversationId(store);
-
- const surfaceMsgs = parseSent(ws).filter(
- (p): p is { type: string; surfaceId: string; conversationId?: string } =>
- (p as { type: string }).type === "subscribe" ||
- (p as { type: string }).type === "unsubscribe",
- );
- // The conversation-scoped surface re-scopes: unsubscribe old + subscribe new id.
- expect(
- surfaceMsgs.some(
- (m) => m.type === "subscribe" && m.surfaceId === "s-conv" && m.conversationId === convId,
- ),
- ).toBe(true);
- // The global surface is untouched — no redundant unsubscribe+subscribe round trip.
- expect(surfaceMsgs.some((m) => m.surfaceId === "s-global")).toBe(false);
-
- store.dispose();
- });
-
- it("tabs persist to the injected storage and restore on a new store", () => {
- const ws = fakeSocket();
- const storage = createFakeStorage();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: storage,
- });
- ws.resolveOpen();
-
- store.send("persist me");
- const convId = store.tabs[0]?.conversationId;
- const title = store.tabs[0]?.title;
- expect(convId).toBeDefined();
- expect(title).toBeDefined();
-
- const raw = storage.getItem("dispatch.tabs");
- expect(raw).not.toBeNull();
- const parsed = JSON.parse(raw as string);
- expect(parsed.tabs).toHaveLength(1);
- expect(parsed.tabs[0].conversationId).toBe(convId);
- expect(parsed.tabs[0].title).toBe(title);
-
- const ws2 = fakeSocket();
- const store2 = createAppStore({
- socketFactory: () => ws2,
- fetchImpl: fakeFetchImpl(),
- localStorage: storage,
- });
- ws2.resolveOpen();
-
- expect(store2.tabs).toHaveLength(1);
- expect(store2.tabs[0]?.conversationId).toBe(convId);
- expect(store2.tabs[0]?.title).toBe(title);
- expect(store2.activeConversationId).toBe(convId);
-
- store.dispose();
- store2.dispose();
- });
-
- it("tabs persist to globalThis.localStorage when no storage is injected", () => {
- const realLs = globalThis.localStorage;
- const memLs = createFakeStorage();
- globalThis.localStorage = memLs;
- try {
- const ws1 = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws1,
- fetchImpl: fakeFetchImpl(),
- });
- ws1.resolveOpen();
-
- store.send("persist via default");
- const convId = store.tabs[0]?.conversationId;
- const title = store.tabs[0]?.title;
- expect(convId).toBeDefined();
- expect(title).toBeDefined();
-
- const raw = globalThis.localStorage.getItem("dispatch.tabs");
- expect(raw).not.toBeNull();
- const parsed = JSON.parse(raw as string);
- expect(parsed.tabs).toHaveLength(1);
- expect(parsed.tabs[0].conversationId).toBe(convId);
- expect(parsed.tabs[0].title).toBe(title);
-
- store.dispose();
-
- const ws2 = fakeSocket();
- const store2 = createAppStore({
- socketFactory: () => ws2,
- fetchImpl: fakeFetchImpl(),
- });
- ws2.resolveOpen();
-
- expect(store2.tabs).toHaveLength(1);
- expect(store2.tabs[0]?.conversationId).toBe(convId);
- expect(store2.tabs[0]?.title).toBe(title);
- expect(store2.activeConversationId).toBe(convId);
-
- store2.dispose();
- } finally {
- globalThis.localStorage = realLs;
- }
- });
-
- it("newDraft resets to draft mode", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("first");
- expect(store.tabs).toHaveLength(1);
-
- store.newDraft();
- expect(store.activeConversationId).toBeNull();
-
- store.dispose();
- });
-
- it("selectTab switches active tab", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("first");
- const convId1 = activeConversationId(store);
-
- store.newDraft();
- store.send("second");
- const convId2 = activeConversationId(store);
-
- store.selectTab(convId1);
- expect(store.activeConversationId).toBe(convId1);
-
- store.selectTab(convId2);
- expect(store.activeConversationId).toBe(convId2);
-
- store.dispose();
- });
-
- it("subscribes to chat for each restored tab on page load", () => {
- const storage = createFakeStorage();
- // First session: create a tab, then dispose.
- const ws1 = fakeSocket();
- const store1 = createAppStore({
- socketFactory: () => ws1,
- fetchImpl: fakeFetchImpl(),
- localStorage: storage,
- });
- ws1.resolveOpen();
- store1.send("persist me");
- const convId = store1.tabs[0]?.conversationId as string;
- expect(convId).toBeDefined();
- store1.dispose();
-
- // Second session: the restored tab must be re-subscribed for live turns.
- const ws2 = fakeSocket();
- const store2 = createAppStore({
- socketFactory: () => ws2,
- fetchImpl: fakeFetchImpl(),
- localStorage: storage,
- });
- ws2.resolveOpen(); // flush the queued chat.subscribe
-
- const subscribed = parseSent(ws2)
- .filter((p) => (p as { type: string }).type === "chat.subscribe")
- .map((p) => (p as { conversationId: string }).conversationId);
- expect(subscribed).toContain(convId);
-
- store2.dispose();
- });
-
- it("unsubscribes from chat when a tab is closed", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("first");
- const convId = activeConversationId(store);
-
- ws.sent.length = 0;
- store.closeTab(convId);
-
- const unsubscribed = parseSent(ws)
- .filter((p) => (p as { type: string }).type === "chat.unsubscribe")
- .map((p) => (p as { conversationId: string }).conversationId);
- expect(unsubscribed).toContain(convId);
-
- store.dispose();
- });
-
- it("re-subscribes chat (and resyncs) for every open conversation on reconnect", async () => {
- const fetchedUrls: string[] = [];
- const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- fetchedUrls.push(url);
- if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
- status: 200,
- });
- }
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
- };
-
- const ws = reconnectableSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl,
- httpUrl: "http://localhost:24203",
- localStorage: createFakeStorage(),
- });
- ws.open();
-
- store.send("hi");
- const convId = activeConversationId(store);
-
- // Drop the connection, wait past the reconnect backoff, then re-open.
- ws.sent.length = 0;
- fetchedUrls.length = 0;
- ws.closeRemote();
- await new Promise((r) => setTimeout(r, 800));
- ws.open(); // reconnect → onReopen
-
- const subscribed = parseSent(ws)
- .filter((p) => (p as { type: string }).type === "chat.subscribe")
- .map((p) => (p as { conversationId: string }).conversationId);
- expect(subscribed).toContain(convId);
-
- // resync() pulled the tail from history for the reconnected conversation.
- await vi.waitFor(() => {
- expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true);
- });
-
- store.dispose();
- });
-
- // ── Heartbeat (workspace-scoped config + runs + watch) ───────────────────────
- //
- // The heartbeat API is a plain REST surface (not a transport-contract type),
- // so these tests fake the four endpoints + verify the store coerces the
- // untyped JSON and routes live deltas to a watch store (the run-chat modal).
-
- function heartbeatFetchImpl(opts?: {
- config?: Record<string, unknown>;
- runs?: Record<string, unknown>;
- }): typeof fetch {
- const base = fakeFetchImpl();
- const config = opts?.config ?? {
- enabled: true,
- systemPrompt: "sys",
- taskPrompt: "task",
- intervalMinutes: 15,
- model: "openai/gpt-4o",
- reasoningEffort: "medium",
- };
- const runs = opts?.runs ?? {
- runs: [
- {
- id: "run-1",
- conversationId: "hb-conv-1",
- triggeredAt: "2026-06-25T10:00:00Z",
- status: "running",
- },
- ],
- };
- return async (input, init) => {
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- const method = init?.method ?? "GET";
- if (url.includes("/heartbeat/runs") && method === "GET") {
- return new Response(JSON.stringify(runs), { status: 200 });
- }
- if (url.includes("/heartbeat/runs/") && method === "POST") {
- return new Response(JSON.stringify({ ok: true }), { status: 200 });
- }
- if (url.endsWith("/heartbeat") && method === "GET") {
- return new Response(JSON.stringify(config), { status: 200 });
- }
- if (url.endsWith("/heartbeat") && method === "PUT") {
- // Echo the patch merged onto the stored config so the round-trip is observable.
- const patch = init?.body ? JSON.parse(init.body as string) : {};
- return new Response(JSON.stringify({ ...config, ...patch }), {
- status: 200,
- });
- }
- if (url.includes("/heartbeat")) {
- return new Response(JSON.stringify(config), { status: 200 });
- }
- return base(input, init);
- };
- }
-
- it("heartbeatConfig loads + coerces the workspace config", async () => {
- const store = createAppStore({
- socketFactory: () => fakeSocket(),
- fetchImpl: heartbeatFetchImpl(),
- localStorage: createFakeStorage(),
- });
- const result = await store.heartbeatConfig();
- expect(result.ok).toBe(true);
- if (!result.ok) throw new Error("unreachable");
- expect(result.config).toEqual({
- enabled: true,
- systemPrompt: "sys",
- taskPrompt: "task",
- intervalMinutes: 15,
- model: "openai/gpt-4o",
- reasoningEffort: "medium",
- });
- store.dispose();
- });
-
- it("heartbeatConfig surfaces an HTTP error", async () => {
- const store = createAppStore({
- socketFactory: () => fakeSocket(),
- fetchImpl: async (input) => {
- const url =
- typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/heartbeat"))
- return new Response(JSON.stringify({ error: "nope" }), { status: 500 });
- return fakeFetchImpl()(input);
- },
- localStorage: createFakeStorage(),
- });
- const result = await store.heartbeatConfig();
- expect(result.ok).toBe(false);
- if (result.ok) throw new Error("unreachable");
- expect(result.error).toContain("nope");
- store.dispose();
- });
-
- it("setHeartbeatConfig PUTs a patch and returns the merged config", async () => {
- const calls: { url: string; method: string; body: unknown }[] = [];
- const store = createAppStore({
- socketFactory: () => fakeSocket(),
- fetchImpl: async (input, init) => {
- const url =
- typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- const method = init?.method ?? "GET";
- if (url.endsWith("/heartbeat") && method === "PUT") {
- calls.push({ url, method, body: JSON.parse(init?.body as string) });
- }
- return heartbeatFetchImpl()(input, init);
- },
- localStorage: createFakeStorage(),
- });
- const result = await store.setHeartbeatConfig({ enabled: false, intervalMinutes: 9999 });
- expect(result.ok).toBe(true);
- expect(calls).toHaveLength(1);
- expect(calls[0]?.body).toEqual({ enabled: false, intervalMinutes: 9999 });
- // The store normalizes the echoed response (interval clamped to the 1–1440 range).
- if (!result.ok) throw new Error("unreachable");
- expect(result.config.intervalMinutes).toBe(1440);
- store.dispose();
- });
-
- it("heartbeatRuns loads + coerces the run list", async () => {
- const store = createAppStore({
- socketFactory: () => fakeSocket(),
- fetchImpl: heartbeatFetchImpl(),
- localStorage: createFakeStorage(),
- });
- const result = await store.heartbeatRuns();
- expect(result.ok).toBe(true);
- if (!result.ok) throw new Error("unreachable");
- expect(result.runs).toHaveLength(1);
- expect(result.runs[0]).toMatchObject({
- id: "run-1",
- conversationId: "hb-conv-1",
- status: "running",
- });
- store.dispose();
- });
-
- it("stopHeartbeatRun POSTs the stop endpoint", async () => {
- const calls: { url: string; method: string }[] = [];
- const store = createAppStore({
- socketFactory: () => fakeSocket(),
- fetchImpl: async (input, init) => {
- const url =
- typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- const method = init?.method ?? "GET";
- if (url.includes("/heartbeat/runs/") && method === "POST") {
- calls.push({ url, method });
- }
- return heartbeatFetchImpl()(input, init);
- },
- localStorage: createFakeStorage(),
- });
- const result = await store.stopHeartbeatRun("run-1");
- expect(result.ok).toBe(true);
- expect(calls).toHaveLength(1);
- expect(calls[0]?.url).toContain("/heartbeat/runs/run-1/stop");
- expect(calls[0]?.method).toBe("POST");
- store.dispose();
- });
-
- it("watchConversation subscribes + routes live deltas to the watch store", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- // A heartbeat run's conversation that is NOT an open tab — watch it.
- const watch = store.watchConversation("hb-conv-watch");
- // A chat.subscribe was sent for the watched conversation.
- const subscribed = parseSent(ws).some(
- (p) =>
- (p as { type: string; conversationId?: string }).type === "chat.subscribe" &&
- (p as { conversationId?: string }).conversationId === "hb-conv-watch",
- );
- expect(subscribed).toBe(true);
-
- // Feed a live delta for the watched conversation → the watch store folds it.
- ws.feedServerMessage({
- type: "chat.delta",
- event: { type: "turn-start", conversationId: "hb-conv-watch", turnId: "t1" },
- });
- ws.feedServerMessage({
- type: "chat.delta",
- event: {
- type: "text-delta",
- conversationId: "hb-conv-watch",
- turnId: "t1",
- delta: "hello from heartbeat",
- },
- });
-
- await vi.waitFor(() => {
- const text = watch.chunks.find((c) => c.role === "assistant" && c.chunk.type === "text");
- expect((text?.chunk as { type: "text"; text: string } | undefined)?.text).toBe(
- "hello from heartbeat",
- );
- });
- expect(watch.generating).toBe(true);
-
- // Unwatch → unsubscribes (a chat.unsubscribe for this conversation is sent).
- ws.sent.length = 0;
- store.unwatchConversation("hb-conv-watch");
- const unsubscribed = parseSent(ws).some(
- (p) =>
- (p as { type: string; conversationId?: string }).type === "chat.unsubscribe" &&
- (p as { conversationId?: string }).conversationId === "hb-conv-watch",
- );
- expect(unsubscribed).toBe(true);
-
- store.dispose();
- });
-
- it("watchConversation reuses an open tab's store; unwatch is a no-op for it", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("first");
- const convId = activeConversationId(store);
- // The conversation is an open tab (already subscribed on send). Watching it
- // must REUSE the tab's store + subscription — so no NEW chat.subscribe is
- // sent (the watch path only subscribes when it creates an ephemeral store).
- // (Note: `store.activeChat` is a Svelte `$state` PROXY of the tab store, so a
- // reference-equality check is meaningless here — we assert behavior instead.)
- ws.sent.length = 0;
- store.watchConversation(convId);
- const subscribed = parseSent(ws).some(
- (p) =>
- (p as { type: string; conversationId?: string }).type === "chat.subscribe" &&
- (p as { conversationId?: string }).conversationId === convId,
- );
- expect(subscribed).toBe(false);
-
- // Unwatching a tab conversation does NOT unsubscribe (the tab keeps its stream).
- ws.sent.length = 0;
- store.unwatchConversation(convId);
- const unsubscribed = parseSent(ws).some(
- (p) => (p as { type: string }).type === "chat.unsubscribe",
- );
- expect(unsubscribed).toBe(false);
-
- store.dispose();
- });
+ it("starts with empty catalog and no surfaces", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ conversationId: "test-conv",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ expect(store.catalog).toEqual([]);
+ expect(store.surfaces).toEqual([]);
+ expect(store.lastError).toBeNull();
+
+ store.dispose();
+ });
+
+ it("updates catalog when catalog message arrives", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ conversationId: "test-conv",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "s1", region: "sidebar", title: "Surface One" },
+ { id: "s2", region: "panel", title: "Surface Two" },
+ ],
+ });
+
+ expect(store.catalog).toHaveLength(2);
+ expect(store.catalog[0]?.id).toBe("s1");
+ expect(store.catalog[1]?.id).toBe("s2");
+
+ store.dispose();
+ });
+
+ it("auto-subscribes to every catalog entry when the catalog arrives", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ conversationId: "test-conv",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.sent.length = 0;
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "s1", region: "sidebar", title: "Surface One" },
+ { id: "s2", region: "panel", title: "Surface Two" },
+ ],
+ });
+
+ const subscribed = ws.sent
+ .map((s) => JSON.parse(s))
+ .filter((p) => p.type === "subscribe")
+ .map((p) => p.surfaceId);
+ expect(subscribed).toContain("s1");
+ expect(subscribed).toContain("s2");
+
+ store.dispose();
+ });
+
+ it("unsubscribes from entries that vanish from a new catalog", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ conversationId: "test-conv",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "s1", region: "sidebar", title: "Surface One" },
+ { id: "s2", region: "panel", title: "Surface Two" },
+ ],
+ });
+
+ ws.sent.length = 0;
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
+ });
+
+ const unsubscribed = ws.sent
+ .map((s) => JSON.parse(s))
+ .filter((p) => p.type === "unsubscribe")
+ .map((p) => p.surfaceId);
+ expect(unsubscribed).toContain("s2");
+ expect(unsubscribed).not.toContain("s1");
+
+ store.dispose();
+ });
+
+ it("exposes received surface specs via `surfaces`, in catalog order", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ conversationId: "test-conv",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "s1", region: "sidebar", title: "Surface One" },
+ { id: "s2", region: "panel", title: "Surface Two" },
+ ],
+ });
+
+ // Only s1's spec has arrived: surfaces reflects what's actually received.
+ ws.feedSurfaceMessage({
+ type: "surface",
+ spec: {
+ id: "s1",
+ region: "sidebar",
+ title: "Surface One",
+ fields: [{ kind: "stat", label: "Tokens", value: "1,234" }],
+ },
+ });
+ expect(store.surfaces.map((s) => s.id)).toEqual(["s1"]);
+
+ ws.feedSurfaceMessage({
+ type: "surface",
+ spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] },
+ });
+ // Catalog order preserved (s1 before s2).
+ expect(store.surfaces.map((s) => s.id)).toEqual(["s1", "s2"]);
+ expect(store.surfaces[0]?.fields).toHaveLength(1);
+
+ store.dispose();
+ });
+
+ it("invoke sends an invoke message", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ conversationId: "test-conv",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.sent.length = 0;
+ store.invoke("s1", "toggle-dark", true);
+
+ const invokeMsg = ws.sent.find((s) => {
+ const parsed = JSON.parse(s);
+ return (
+ parsed.type === "invoke" &&
+ parsed.surfaceId === "s1" &&
+ parsed.actionId === "toggle-dark" &&
+ parsed.payload === true
+ );
+ });
+ expect(invokeMsg).toBeTruthy();
+
+ store.dispose();
+ });
+
+ it("error message updates lastError", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ conversationId: "test-conv",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.feedSurfaceMessage({
+ type: "error",
+ message: "Something went wrong",
+ });
+
+ expect(store.lastError).not.toBeNull();
+ expect(store.lastError?.message).toBe("Something went wrong");
+
+ store.dispose();
+ });
+
+ it("dispose closes the socket", () => {
+ const ws = fakeSocket();
+ const closeSpy = { called: false };
+ const origClose = ws.close.bind(ws);
+ ws.close = () => {
+ closeSpy.called = true;
+ origClose();
+ };
+
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ conversationId: "test-conv",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.dispose();
+ expect(closeSpy.called).toBe(true);
+ });
+
+ it("exposes activeChat with empty initial messages", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ expect(store.activeChat).toBeDefined();
+ expect(store.activeChat.messages).toEqual([]);
+ expect(store.activeChat.chunks).toEqual([]);
+ expect(store.activeChat.error).toBeNull();
+
+ store.dispose();
+ });
+
+ it("sending a message from draft creates a tab and posts chat.send", () => {
+ const ws = fakeSocket();
+ const storage = createFakeStorage();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws.resolveOpen();
+
+ ws.sent.length = 0;
+ store.send("hello world");
+
+ expect(store.tabs).toHaveLength(1);
+ expect(store.tabs[0]?.title).toBe("hello world");
+ expect(store.activeConversationId).not.toBeNull();
+
+ const msgs = parseSent(ws);
+ const chatSend = msgs.find((m) => (m as { type: string }).type === "chat.send") as
+ | { type: string; conversationId: string; message: string }
+ | undefined;
+ expect(chatSend).toBeTruthy();
+ expect(chatSend?.message).toBe("hello world");
+
+ store.dispose();
+ });
+
+ it("an incoming chat.delta renders in the transcript", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("test");
+ const convId = activeConversationId(store);
+
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: convId, turnId: "turn-1" },
+ });
+
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "Hello " },
+ });
+
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "world" },
+ });
+
+ expect(store.activeChat.chunks.length).toBeGreaterThan(0);
+ const assistantChunks = store.activeChat.chunks.filter(
+ (c) => c.role === "assistant" && c.chunk.type === "text",
+ );
+ expect(assistantChunks).toHaveLength(1);
+ expect((assistantChunks[0]?.chunk as { type: "text"; text: string }).text).toBe("Hello world");
+
+ store.dispose();
+ });
+
+ it("chat.error sets the chat error", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("test");
+ const convId = activeConversationId(store);
+
+ ws.feedServerMessage({
+ type: "chat.error",
+ conversationId: convId,
+ message: "bad request",
+ });
+
+ expect(store.activeChat.error).toBe("bad request");
+
+ store.dispose();
+ });
+
+ it("turn-sealed triggers a history fetch and synced chunks render", async () => {
+ const fetchedUrls: string[] = [];
+ const historyResponse: ConversationHistoryResponse = {
+ chunks: [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hi" } },
+ { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } },
+ ],
+ latestSeq: 2,
+ };
+ const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ fetchedUrls.push(url);
+ if (url.endsWith("/models")) {
+ return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ status: 200,
+ });
+ }
+ return new Response(JSON.stringify(historyResponse), { status: 200 });
+ };
+
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ httpUrl: "http://localhost:24203",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("hi");
+ const convId = activeConversationId(store);
+
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: convId, turnId: "turn-1" },
+ });
+
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-sealed", conversationId: convId, turnId: "turn-1" },
+ });
+
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true);
+
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(store.activeChat.chunks.length).toBeGreaterThan(0);
+
+ store.dispose();
+ });
+
+ it("fetches and exposes the model catalog", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({
+ models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"],
+ }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(store.models).toEqual([
+ "opencode/deepseek-v4-flash",
+ "openai/gpt-4o",
+ "anthropic/claude-3",
+ ]);
+
+ store.dispose();
+ });
+
+ it("default model is flash", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ expect(store.activeModel).toBe("opencode/deepseek-v4-flash");
+
+ store.dispose();
+ });
+
+ it("draft: sending the first message creates a tab titled from the message", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ expect(store.tabs).toHaveLength(0);
+ expect(store.activeConversationId).toBeNull();
+
+ store.send("What is the meaning of life?");
+
+ expect(store.tabs).toHaveLength(1);
+ expect(store.tabs[0]?.title).toBe("What is the meaning of life?");
+ expect(store.activeConversationId).toBe(store.tabs[0]?.conversationId);
+
+ store.dispose();
+ });
+
+ it("selecting a model persists it to the backend", () => {
+ const ws = fakeSocket();
+ const fetchImpl = fakeFetchImpl();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("hello");
+ store.selectModel("openai/gpt-4o");
+
+ const put = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ conversationId: "ignored", model: "openai/gpt-4o" }), {
+ status: 200,
+ }),
+ );
+ const capturingStore = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (init?.method === "PUT" && url.endsWith("/model")) {
+ put(url, init);
+ }
+ return fetchImpl(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ capturingStore.send("hello");
+ capturingStore.selectModel("openai/gpt-4o");
+
+ expect(put).toHaveBeenCalledOnce();
+ const [callUrl, callInit] = put.mock.calls[0] as [string, RequestInit];
+ expect(callUrl.endsWith("/model")).toBe(true);
+ expect(JSON.parse(callInit.body as string)).toEqual({ model: "openai/gpt-4o" });
+
+ store.dispose();
+ capturingStore.dispose();
+ });
+
+ it("focuses a conversation with a persisted model", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ model: "openai/gpt-4o" }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first message");
+
+ // New tab opens with the default model until the persisted-model fetch resolves.
+ expect(store.activeModel).toBe("opencode/deepseek-v4-flash");
+
+ // Wait for the persisted model fetch to resolve.
+ await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o"));
+ expect(store.tabs[0]?.model).toBe("openai/gpt-4o");
+
+ store.dispose();
+ });
+
+ it("chat.delta routes to the matching tab only", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first message");
+ const convId1 = activeConversationId(store);
+
+ store.newDraft();
+ store.send("second message");
+ const convId2 = activeConversationId(store);
+
+ expect(convId1).not.toBe(convId2);
+
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: convId1, turnId: "turn-1" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: {
+ type: "text-delta",
+ conversationId: convId1,
+ turnId: "turn-1",
+ delta: "response to first",
+ },
+ });
+
+ store.selectTab(convId1);
+ const assistantChunks1 = store.activeChat.chunks.filter(
+ (c) => c.role === "assistant" && c.chunk.type === "text",
+ );
+ expect(assistantChunks1).toHaveLength(1);
+ expect((assistantChunks1[0]?.chunk as { type: "text"; text: string }).text).toBe(
+ "response to first",
+ );
+
+ store.selectTab(convId2);
+ const assistantChunks2 = store.activeChat.chunks.filter(
+ (c) => c.role === "assistant" && c.chunk.type === "text",
+ );
+ expect(assistantChunks2).toEqual([]);
+
+ store.dispose();
+ });
+
+ it("closing a tab evicts its cache and drops the tab", () => {
+ const ws = fakeSocket();
+ const storage = createFakeStorage();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ const convId = activeConversationId(store);
+ expect(store.tabs).toHaveLength(1);
+
+ store.closeTab(convId);
+
+ expect(store.tabs).toHaveLength(0);
+ expect(store.activeConversationId).toBeNull();
+
+ store.dispose();
+ });
+
+ it("closing a tab POSTs /conversations/:id/close (abort turn + stop warming)", async () => {
+ const calls: { url: string; method: string }[] = [];
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ calls.push({ url, method: init?.method ?? "GET" });
+ if (url.endsWith("/close")) {
+ return new Response(
+ JSON.stringify({ conversationId: url.split("/").at(-2), abortedTurn: false }),
+ { status: 200 },
+ );
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ const convId = activeConversationId(store);
+ store.closeTab(convId);
+ await Promise.resolve(); // flush the fire-and-forget fetch
+
+ const close = calls.find((c) => c.url.endsWith(`/conversations/${convId}/close`));
+ expect(close).toBeDefined();
+ expect(close?.method).toBe("POST");
+
+ store.dispose();
+ });
+
+ it("seeds reasoningEffort from GET /conversations/:id/reasoning-effort (null = never set)", async () => {
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/reasoning-effort")) {
+ return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: "xhigh" }), {
+ status: 200,
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await vi.waitFor(() => {
+ expect(store.reasoningEffort).toBe("xhigh");
+ });
+
+ store.dispose();
+ });
+
+ it("setReasoningEffort PUTs the level and updates local state from the echo", async () => {
+ const calls: { url: string; method: string; body: string | undefined }[] = [];
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ calls.push({ url, method: init?.method ?? "GET", body: init?.body as string | undefined });
+ if (url.endsWith("/reasoning-effort") && init?.method === "PUT") {
+ const sent = JSON.parse(init.body as string) as { reasoningEffort: string };
+ return new Response(
+ JSON.stringify({ conversationId: "x", reasoningEffort: sent.reasoningEffort }),
+ { status: 200 },
+ );
+ }
+ if (url.endsWith("/reasoning-effort")) {
+ return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), {
+ status: 200,
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.setReasoningEffort("max");
+ expect(result).toEqual({ ok: true, reasoningEffort: "max" });
+ expect(store.reasoningEffort).toBe("max");
+
+ const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/reasoning-effort"));
+ expect(put).toBeDefined();
+ // The PUT targets the workspace conversation (draft id works too) and
+ // carries exactly the SetReasoningEffortRequest body.
+ expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`);
+ expect(JSON.parse(put?.body ?? "{}")).toEqual({ reasoningEffort: "max" });
+
+ store.dispose();
+ });
+
+ it("setReasoningEffort surfaces a 400 error and leaves state unchanged", async () => {
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/reasoning-effort") && init?.method === "PUT") {
+ return new Response(JSON.stringify({ error: "bad level" }), { status: 400 });
+ }
+ if (url.endsWith("/reasoning-effort")) {
+ return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), {
+ status: 200,
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.setReasoningEffort("max");
+ expect(result).toEqual({ ok: false, error: "bad level" });
+ expect(store.reasoningEffort).toBeNull();
+
+ store.dispose();
+ });
+
+ it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "s-global", region: "side", title: "Global", scope: "global" },
+ { id: "s-conv", region: "side", title: "Scoped", scope: "conversation" },
+ ],
+ });
+
+ ws.sent.length = 0;
+ store.send("promote the draft"); // draft → real conversation: surfaces re-scope
+ const convId = activeConversationId(store);
+
+ const surfaceMsgs = parseSent(ws).filter(
+ (p): p is { type: string; surfaceId: string; conversationId?: string } =>
+ (p as { type: string }).type === "subscribe" ||
+ (p as { type: string }).type === "unsubscribe",
+ );
+ // The conversation-scoped surface re-scopes: unsubscribe old + subscribe new id.
+ expect(
+ surfaceMsgs.some(
+ (m) => m.type === "subscribe" && m.surfaceId === "s-conv" && m.conversationId === convId,
+ ),
+ ).toBe(true);
+ // The global surface is untouched — no redundant unsubscribe+subscribe round trip.
+ expect(surfaceMsgs.some((m) => m.surfaceId === "s-global")).toBe(false);
+
+ store.dispose();
+ });
+
+ it("tabs persist to the injected storage and restore on a new store", () => {
+ const ws = fakeSocket();
+ const storage = createFakeStorage();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws.resolveOpen();
+
+ store.send("persist me");
+ const convId = store.tabs[0]?.conversationId;
+ const title = store.tabs[0]?.title;
+ expect(convId).toBeDefined();
+ expect(title).toBeDefined();
+
+ const raw = storage.getItem("dispatch.tabs");
+ expect(raw).not.toBeNull();
+ const parsed = JSON.parse(raw as string);
+ expect(parsed.tabs).toHaveLength(1);
+ expect(parsed.tabs[0].conversationId).toBe(convId);
+ expect(parsed.tabs[0].title).toBe(title);
+
+ const ws2 = fakeSocket();
+ const store2 = createAppStore({
+ socketFactory: () => ws2,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws2.resolveOpen();
+
+ expect(store2.tabs).toHaveLength(1);
+ expect(store2.tabs[0]?.conversationId).toBe(convId);
+ expect(store2.tabs[0]?.title).toBe(title);
+ expect(store2.activeConversationId).toBe(convId);
+
+ store.dispose();
+ store2.dispose();
+ });
+
+ it("tabs persist to globalThis.localStorage when no storage is injected", () => {
+ const realLs = globalThis.localStorage;
+ const memLs = createFakeStorage();
+ globalThis.localStorage = memLs;
+ try {
+ const ws1 = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws1,
+ fetchImpl: fakeFetchImpl(),
+ });
+ ws1.resolveOpen();
+
+ store.send("persist via default");
+ const convId = store.tabs[0]?.conversationId;
+ const title = store.tabs[0]?.title;
+ expect(convId).toBeDefined();
+ expect(title).toBeDefined();
+
+ const raw = globalThis.localStorage.getItem("dispatch.tabs");
+ expect(raw).not.toBeNull();
+ const parsed = JSON.parse(raw as string);
+ expect(parsed.tabs).toHaveLength(1);
+ expect(parsed.tabs[0].conversationId).toBe(convId);
+ expect(parsed.tabs[0].title).toBe(title);
+
+ store.dispose();
+
+ const ws2 = fakeSocket();
+ const store2 = createAppStore({
+ socketFactory: () => ws2,
+ fetchImpl: fakeFetchImpl(),
+ });
+ ws2.resolveOpen();
+
+ expect(store2.tabs).toHaveLength(1);
+ expect(store2.tabs[0]?.conversationId).toBe(convId);
+ expect(store2.tabs[0]?.title).toBe(title);
+ expect(store2.activeConversationId).toBe(convId);
+
+ store2.dispose();
+ } finally {
+ globalThis.localStorage = realLs;
+ }
+ });
+
+ it("newDraft resets to draft mode", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ expect(store.tabs).toHaveLength(1);
+
+ store.newDraft();
+ expect(store.activeConversationId).toBeNull();
+
+ store.dispose();
+ });
+
+ it("selectTab switches active tab", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ const convId1 = activeConversationId(store);
+
+ store.newDraft();
+ store.send("second");
+ const convId2 = activeConversationId(store);
+
+ store.selectTab(convId1);
+ expect(store.activeConversationId).toBe(convId1);
+
+ store.selectTab(convId2);
+ expect(store.activeConversationId).toBe(convId2);
+
+ store.dispose();
+ });
+
+ it("subscribes to chat for each restored tab on page load", () => {
+ const storage = createFakeStorage();
+ // First session: create a tab, then dispose.
+ const ws1 = fakeSocket();
+ const store1 = createAppStore({
+ socketFactory: () => ws1,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws1.resolveOpen();
+ store1.send("persist me");
+ const convId = store1.tabs[0]?.conversationId as string;
+ expect(convId).toBeDefined();
+ store1.dispose();
+
+ // Second session: the restored tab must be re-subscribed for live turns.
+ const ws2 = fakeSocket();
+ const store2 = createAppStore({
+ socketFactory: () => ws2,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws2.resolveOpen(); // flush the queued chat.subscribe
+
+ const subscribed = parseSent(ws2)
+ .filter((p) => (p as { type: string }).type === "chat.subscribe")
+ .map((p) => (p as { conversationId: string }).conversationId);
+ expect(subscribed).toContain(convId);
+
+ store2.dispose();
+ });
+
+ it("unsubscribes from chat when a tab is closed", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ const convId = activeConversationId(store);
+
+ ws.sent.length = 0;
+ store.closeTab(convId);
+
+ const unsubscribed = parseSent(ws)
+ .filter((p) => (p as { type: string }).type === "chat.unsubscribe")
+ .map((p) => (p as { conversationId: string }).conversationId);
+ expect(unsubscribed).toContain(convId);
+
+ store.dispose();
+ });
+
+ it("re-subscribes chat (and resyncs) for every open conversation on reconnect", async () => {
+ const fetchedUrls: string[] = [];
+ const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ fetchedUrls.push(url);
+ if (url.endsWith("/models")) {
+ return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ status: 200,
+ });
+ }
+ return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ };
+
+ const ws = reconnectableSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ httpUrl: "http://localhost:24203",
+ localStorage: createFakeStorage(),
+ });
+ ws.open();
+
+ store.send("hi");
+ const convId = activeConversationId(store);
+
+ // Drop the connection, wait past the reconnect backoff, then re-open.
+ ws.sent.length = 0;
+ fetchedUrls.length = 0;
+ ws.closeRemote();
+ await new Promise((r) => setTimeout(r, 800));
+ ws.open(); // reconnect → onReopen
+
+ const subscribed = parseSent(ws)
+ .filter((p) => (p as { type: string }).type === "chat.subscribe")
+ .map((p) => (p as { conversationId: string }).conversationId);
+ expect(subscribed).toContain(convId);
+
+ // resync() pulled the tail from history for the reconnected conversation.
+ await vi.waitFor(() => {
+ expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true);
+ });
+
+ store.dispose();
+ });
+
+ // ── Heartbeat (workspace-scoped config + runs + watch) ───────────────────────
+ //
+ // The heartbeat API is a plain REST surface (not a transport-contract type),
+ // so these tests fake the four endpoints + verify the store coerces the
+ // untyped JSON and routes live deltas to a watch store (the run-chat modal).
+
+ function heartbeatFetchImpl(opts?: {
+ config?: Record<string, unknown>;
+ runs?: Record<string, unknown>;
+ }): typeof fetch {
+ const base = fakeFetchImpl();
+ const config = opts?.config ?? {
+ enabled: true,
+ systemPrompt: "sys",
+ taskPrompt: "task",
+ intervalMinutes: 15,
+ model: "openai/gpt-4o",
+ reasoningEffort: "medium",
+ };
+ const runs = opts?.runs ?? {
+ runs: [
+ {
+ id: "run-1",
+ conversationId: "hb-conv-1",
+ triggeredAt: "2026-06-25T10:00:00Z",
+ status: "running",
+ },
+ ],
+ };
+ return async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/heartbeat/runs") && method === "GET") {
+ return new Response(JSON.stringify(runs), { status: 200 });
+ }
+ if (url.includes("/heartbeat/runs/") && method === "POST") {
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
+ }
+ if (url.endsWith("/heartbeat") && method === "GET") {
+ return new Response(JSON.stringify(config), { status: 200 });
+ }
+ if (url.endsWith("/heartbeat") && method === "PUT") {
+ // Echo the patch merged onto the stored config so the round-trip is observable.
+ const patch = init?.body ? JSON.parse(init.body as string) : {};
+ return new Response(JSON.stringify({ ...config, ...patch }), {
+ status: 200,
+ });
+ }
+ if (url.includes("/heartbeat")) {
+ return new Response(JSON.stringify(config), { status: 200 });
+ }
+ return base(input, init);
+ };
+ }
+
+ it("heartbeatConfig loads + coerces the workspace config", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: heartbeatFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.heartbeatConfig();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.config).toEqual({
+ enabled: true,
+ systemPrompt: "sys",
+ taskPrompt: "task",
+ intervalMinutes: 15,
+ model: "openai/gpt-4o",
+ reasoningEffort: "medium",
+ });
+ store.dispose();
+ });
+
+ it("heartbeatConfig surfaces an HTTP error", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/heartbeat"))
+ return new Response(JSON.stringify({ error: "nope" }), { status: 500 });
+ return fakeFetchImpl()(input);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.heartbeatConfig();
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("nope");
+ store.dispose();
+ });
+
+ it("setHeartbeatConfig PUTs a patch and returns the merged config", async () => {
+ const calls: { url: string; method: string; body: unknown }[] = [];
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.endsWith("/heartbeat") && method === "PUT") {
+ calls.push({ url, method, body: JSON.parse(init?.body as string) });
+ }
+ return heartbeatFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.setHeartbeatConfig({ enabled: false, intervalMinutes: 9999 });
+ expect(result.ok).toBe(true);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.body).toEqual({ enabled: false, intervalMinutes: 9999 });
+ // The store normalizes the echoed response (interval clamped to the 1–1440 range).
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.config.intervalMinutes).toBe(1440);
+ store.dispose();
+ });
+
+ it("heartbeatRuns loads + coerces the run list", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: heartbeatFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.heartbeatRuns();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.runs).toHaveLength(1);
+ expect(result.runs[0]).toMatchObject({
+ id: "run-1",
+ conversationId: "hb-conv-1",
+ status: "running",
+ });
+ store.dispose();
+ });
+
+ it("stopHeartbeatRun POSTs the stop endpoint", async () => {
+ const calls: { url: string; method: string }[] = [];
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/heartbeat/runs/") && method === "POST") {
+ calls.push({ url, method });
+ }
+ return heartbeatFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.stopHeartbeatRun("run-1");
+ expect(result.ok).toBe(true);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.url).toContain("/heartbeat/runs/run-1/stop");
+ expect(calls[0]?.method).toBe("POST");
+ store.dispose();
+ });
+
+ it("watchConversation subscribes + routes live deltas to the watch store", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // A heartbeat run's conversation that is NOT an open tab — watch it.
+ const watch = store.watchConversation("hb-conv-watch");
+ // A chat.subscribe was sent for the watched conversation.
+ const subscribed = parseSent(ws).some(
+ (p) =>
+ (p as { type: string; conversationId?: string }).type === "chat.subscribe" &&
+ (p as { conversationId?: string }).conversationId === "hb-conv-watch",
+ );
+ expect(subscribed).toBe(true);
+
+ // Feed a live delta for the watched conversation → the watch store folds it.
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: "hb-conv-watch", turnId: "t1" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: {
+ type: "text-delta",
+ conversationId: "hb-conv-watch",
+ turnId: "t1",
+ delta: "hello from heartbeat",
+ },
+ });
+
+ await vi.waitFor(() => {
+ const text = watch.chunks.find((c) => c.role === "assistant" && c.chunk.type === "text");
+ expect((text?.chunk as { type: "text"; text: string } | undefined)?.text).toBe(
+ "hello from heartbeat",
+ );
+ });
+ expect(watch.generating).toBe(true);
+
+ // Unwatch → unsubscribes (a chat.unsubscribe for this conversation is sent).
+ ws.sent.length = 0;
+ store.unwatchConversation("hb-conv-watch");
+ const unsubscribed = parseSent(ws).some(
+ (p) =>
+ (p as { type: string; conversationId?: string }).type === "chat.unsubscribe" &&
+ (p as { conversationId?: string }).conversationId === "hb-conv-watch",
+ );
+ expect(unsubscribed).toBe(true);
+
+ store.dispose();
+ });
+
+ it("watchConversation reuses an open tab's store; unwatch is a no-op for it", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ const convId = activeConversationId(store);
+ // The conversation is an open tab (already subscribed on send). Watching it
+ // must REUSE the tab's store + subscription — so no NEW chat.subscribe is
+ // sent (the watch path only subscribes when it creates an ephemeral store).
+ // (Note: `store.activeChat` is a Svelte `$state` PROXY of the tab store, so a
+ // reference-equality check is meaningless here — we assert behavior instead.)
+ ws.sent.length = 0;
+ store.watchConversation(convId);
+ const subscribed = parseSent(ws).some(
+ (p) =>
+ (p as { type: string; conversationId?: string }).type === "chat.subscribe" &&
+ (p as { conversationId?: string }).conversationId === convId,
+ );
+ expect(subscribed).toBe(false);
+
+ // Unwatching a tab conversation does NOT unsubscribe (the tab keeps its stream).
+ ws.sent.length = 0;
+ store.unwatchConversation(convId);
+ const unsubscribed = parseSent(ws).some(
+ (p) => (p as { type: string }).type === "chat.unsubscribe",
+ );
+ expect(unsubscribed).toBe(false);
+
+ store.dispose();
+ });
});
diff --git a/src/app/uuid.test.ts b/src/app/uuid.test.ts
index bd8e306..7673db1 100644
--- a/src/app/uuid.test.ts
+++ b/src/app/uuid.test.ts
@@ -4,28 +4,28 @@ import { randomId } from "./uuid";
const V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
describe("randomId", () => {
- it("returns a v4-shaped uuid", () => {
- const id = randomId();
- expect(id).toMatch(V4_RE);
- });
+ it("returns a v4-shaped uuid", () => {
+ const id = randomId();
+ expect(id).toMatch(V4_RE);
+ });
- it("returns distinct values across calls", () => {
- const ids = new Set<string>();
- for (let i = 0; i < 200; i++) {
- ids.add(randomId());
- }
- expect(ids.size).toBe(200);
- });
+ it("returns distinct values across calls", () => {
+ const ids = new Set<string>();
+ for (let i = 0; i < 200; i++) {
+ ids.add(randomId());
+ }
+ expect(ids.size).toBe(200);
+ });
- it("works without crypto.randomUUID (getRandomValues branch)", () => {
- const origRandomUUID = crypto.randomUUID;
- try {
- // Remove randomUUID so the getRandomValues branch is taken
- delete (crypto as { randomUUID?: () => string }).randomUUID;
- const id = randomId();
- expect(id).toMatch(V4_RE);
- } finally {
- crypto.randomUUID = origRandomUUID;
- }
- });
+ it("works without crypto.randomUUID (getRandomValues branch)", () => {
+ const origRandomUUID = crypto.randomUUID;
+ try {
+ // Remove randomUUID so the getRandomValues branch is taken
+ delete (crypto as { randomUUID?: () => string }).randomUUID;
+ const id = randomId();
+ expect(id).toMatch(V4_RE);
+ } finally {
+ crypto.randomUUID = origRandomUUID;
+ }
+ });
});
diff --git a/src/app/uuid.ts b/src/app/uuid.ts
index ae39d4d..bdceefe 100644
--- a/src/app/uuid.ts
+++ b/src/app/uuid.ts
@@ -1,65 +1,65 @@
const HEX = "0123456789abcdef";
function hexChar(n: number): string {
- return HEX.charAt(n & 0xf);
+ return HEX.charAt(n & 0xf);
}
function hexFromBytes(bytes: Uint8Array): string {
- let out = "";
- for (let i = 0; i < bytes.length; i++) {
- const b = bytes[i] as number;
- out += hexChar(b >> 4);
- out += hexChar(b);
- }
- return out;
+ let out = "";
+ for (let i = 0; i < bytes.length; i++) {
+ const b = bytes[i] as number;
+ out += hexChar(b >> 4);
+ out += hexChar(b);
+ }
+ return out;
}
function formatV4(rand: Uint8Array): string {
- const h = hexFromBytes(rand);
- return (
- h.slice(0, 8) +
- "-" +
- h.slice(8, 12) +
- "-4" +
- h.slice(13, 16) +
- "-" +
- ((parseInt(h.slice(16, 18), 16) & 0x3f) | 0x80).toString(16).padStart(2, "0") +
- h.slice(18, 20) +
- "-" +
- h.slice(20, 32)
- );
+ const h = hexFromBytes(rand);
+ return (
+ h.slice(0, 8) +
+ "-" +
+ h.slice(8, 12) +
+ "-4" +
+ h.slice(13, 16) +
+ "-" +
+ ((parseInt(h.slice(16, 18), 16) & 0x3f) | 0x80).toString(16).padStart(2, "0") +
+ h.slice(18, 20) +
+ "-" +
+ h.slice(20, 32)
+ );
}
function uuidFromGetRandomValues(): string {
- const buf = new Uint8Array(16);
- crypto.getRandomValues(buf);
- buf[6] = ((buf[6] as number) & 0x0f) | 0x40;
- buf[8] = ((buf[8] as number) & 0x3f) | 0x80;
- return formatV4(buf);
+ const buf = new Uint8Array(16);
+ crypto.getRandomValues(buf);
+ buf[6] = ((buf[6] as number) & 0x0f) | 0x40;
+ buf[8] = ((buf[8] as number) & 0x3f) | 0x80;
+ return formatV4(buf);
}
function uuidFromMathRandom(): string {
- let s = "";
- for (let i = 0; i < 36; i++) {
- if (i === 8 || i === 13 || i === 18 || i === 23) {
- s += "-";
- } else if (i === 14) {
- s += "4";
- } else if (i === 19) {
- s += hexChar(Math.floor(Math.random() * 4) + 8);
- } else {
- s += hexChar(Math.floor(Math.random() * 16));
- }
- }
- return s;
+ let s = "";
+ for (let i = 0; i < 36; i++) {
+ if (i === 8 || i === 13 || i === 18 || i === 23) {
+ s += "-";
+ } else if (i === 14) {
+ s += "4";
+ } else if (i === 19) {
+ s += hexChar(Math.floor(Math.random() * 4) + 8);
+ } else {
+ s += hexChar(Math.floor(Math.random() * 16));
+ }
+ }
+ return s;
}
export function randomId(): string {
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
- return crypto.randomUUID();
- }
- if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
- return uuidFromGetRandomValues();
- }
- return uuidFromMathRandom();
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
+ return crypto.randomUUID();
+ }
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
+ return uuidFromGetRandomValues();
+ }
+ return uuidFromMathRandom();
}