From 154f298fe9a6e41a09229ab3a1dd10e0856e73e4 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:17:11 +1000 Subject: [PATCH] fix(app): unify v2 server and session lifecycle (#41930) --- .../performance/timeline-stability/fixture.ts | 27 +- .../e2e/performance/unit/mock-server.test.ts | 1 + .../regression/cross-server-tab-close.spec.ts | 8 +- .../remote-session-settings.spec.ts | 8 +- .../regression/session-request-docks.spec.ts | 64 ++-- .../session-timeline-notices.spec.ts | 188 ++++++++++ packages/app/e2e/utils/mock-server.ts | 115 ++++++ packages/app/src/app.tsx | 27 +- .../components/dialog-connect-provider.tsx | 22 +- .../dialog-select-model-unpaid-v2.tsx | 7 +- .../provider-connection-controller.ts | 1 + .../src/components/settings-server-picker.tsx | 25 +- .../settings-v2/dialog-settings-v2.tsx | 2 +- .../src/components/settings-v2/providers.tsx | 4 +- packages/app/src/context/file/watcher.test.ts | 10 +- packages/app/src/context/file/watcher.ts | 2 +- .../app/src/context/global-sync/bootstrap.ts | 244 +++++++------ .../context/global-sync/child-store.test.ts | 112 +++++- .../src/context/global-sync/child-store.ts | 46 ++- .../context/global-sync/event-reducer.test.ts | 4 +- .../src/context/global-sync/event-reducer.ts | 3 +- .../context/global-sync/session-cache.test.ts | 7 +- .../src/context/global-sync/session-cache.ts | 4 +- packages/app/src/context/global.tsx | 11 - packages/app/src/context/language.tsx | 2 + packages/app/src/context/notification.tsx | 13 +- packages/app/src/context/server-sdk.tsx | 185 +++++++--- .../context/server-session-v2-reducer.test.ts | 52 +++ .../src/context/server-session-v2-reducer.ts | 72 +++- .../app/src/context/server-session.test.ts | 194 ++++++++++ packages/app/src/context/server-session.ts | 155 +++++++- packages/app/src/context/server-sync.test.ts | 19 +- packages/app/src/context/server-sync.tsx | 330 ++++++++++++------ .../src/context/server-sync/catalog.test.ts | 51 +++ .../app/src/context/server-sync/catalog.ts | 53 +++ .../context/server-sync/connection.test.ts | 23 ++ .../app/src/context/server-sync/connection.ts | 22 ++ packages/app/src/hooks/use-integrations.ts | 21 ++ packages/app/src/hooks/use-providers.ts | 10 +- packages/app/src/i18n/en.ts | 11 + packages/app/src/i18n/parity.test.ts | 279 --------------- packages/app/src/pages/directory-layout.tsx | 5 +- .../pages/home/home-sessions-controller.tsx | 2 +- .../src/pages/layout/project-avatar-state.ts | 4 +- packages/app/src/pages/session.tsx | 36 +- .../composer/session-background-dock.tsx | 86 +++++ .../composer/session-composer-controls.ts | 9 +- .../composer/session-composer-pullout.tsx | 157 +++++++++ .../composer/session-composer-region.tsx | 14 +- .../composer/session-composer-state.test.ts | 25 +- .../composer/session-composer-state.ts | 144 +++++++- .../composer/session-question-dock.tsx | 59 +++- .../session/composer/session-request-tree.ts | 10 +- .../session/composer/session-todo-dock.tsx | 178 ++-------- .../session/timeline/message-timeline.tsx | 78 ++++- .../src/pages/session/timeline/projection.ts | 4 + .../session/timeline/rows-current.test.ts | 78 +++++ .../app/src/pages/session/timeline/rows.ts | 148 +++++--- .../pages/session/timeline/timeline-row.ts | 7 + .../pages/session/use-session-commands.tsx | 11 + .../session/v2/session-file-browser-tab.tsx | 6 +- packages/app/vite.js | 2 +- packages/core/src/tool/plugin/shell.ts | 3 +- packages/desktop/src/renderer/diagnostics.ts | 99 ++++++ packages/desktop/src/renderer/index.tsx | 1 + .../src/components/message-part.tsx | 41 +-- 66 files changed, 2646 insertions(+), 995 deletions(-) create mode 100644 packages/app/e2e/regression/session-timeline-notices.spec.ts create mode 100644 packages/app/src/context/server-sync/catalog.test.ts create mode 100644 packages/app/src/context/server-sync/catalog.ts create mode 100644 packages/app/src/context/server-sync/connection.test.ts create mode 100644 packages/app/src/context/server-sync/connection.ts create mode 100644 packages/app/src/hooks/use-integrations.ts delete mode 100644 packages/app/src/i18n/parity.test.ts create mode 100644 packages/app/src/pages/session/composer/session-background-dock.tsx create mode 100644 packages/app/src/pages/session/composer/session-composer-pullout.tsx create mode 100644 packages/desktop/src/renderer/diagnostics.ts diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index 513cc378671..024e5edd59e 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -2,7 +2,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { Event } from "@opencode-ai/schema/event" import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event" import { SessionV1 } from "@opencode-ai/schema/session-v1" -import type { SessionInfo, SessionStatus } from "@opencode-ai/client/promise" +import type { SessionInfo, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise" import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types" import { expect, type Page } from "@playwright/test" import { Schema } from "effect" @@ -90,6 +90,8 @@ export async function setupTimeline( page: Page, input: { messages?: TimelineMessage[] + currentMessages?: SessionMessageInfo[] + sessionStatus?: Record settings?: Record sessions?: Session[] cpuRate?: number @@ -102,13 +104,22 @@ export async function setupTimeline( } = {}, ) { const sessions = input.sessions ?? [session()] - const messages = validateTimelineMessages([ - ...(input.seedHistory ? historyMessages(18) : []), - ...(input.messages ?? [userMessage(), assistantMessage()]), - ]) - const active = messages.findLast((message) => message.info.role === "assistant") + const messages = + input.currentMessages ?? + validateTimelineMessages([ + ...(input.seedHistory ? historyMessages(18) : []), + ...(input.messages ?? [userMessage(), assistantMessage()]), + ]) + const active = messages.findLast((message) => + "info" in message ? message.info.role === "assistant" : message.type === "assistant", + ) const initialStatus = decodeStatus( - active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" }, + active && + ("info" in active + ? active.info.role === "assistant" && active.info.time.completed === undefined + : active.type === "assistant" && active.time.completed === undefined) + ? { type: "busy" } + : { type: "idle" }, decodeOptions, ) const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` @@ -122,7 +133,7 @@ export async function setupTimeline( project: project(), provider: provider(), sessions, - sessionStatus: { [sessionID]: initialStatus }, + sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus }, pageMessages: () => ({ items: messages, }), diff --git a/packages/app/e2e/performance/unit/mock-server.test.ts b/packages/app/e2e/performance/unit/mock-server.test.ts index 8af12a948cb..01c5d29a73a 100644 --- a/packages/app/e2e/performance/unit/mock-server.test.ts +++ b/packages/app/e2e/performance/unit/mock-server.test.ts @@ -122,6 +122,7 @@ test("applies message latency after a list response gate is released", async () const gate = Promise.withResolvers() let handler: ((route: Route) => Promise) | undefined const page = { + addInitScript: () => Promise.resolve(), route: (_url: string, callback: (route: Route) => Promise) => { handler = callback return Promise.resolve() diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index a75efa4d98f..32c27dd8657 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" import { currentSession } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" const serverA = "http://127.0.0.1:4096" const serverB = "http://127.0.0.1:4097" @@ -78,6 +79,8 @@ function session(id: string, directory: string, title: string) { } async function mockServers(page: Page, requests: string[]) { + await installSseTransport(page, { server: serverA }) + await installSseTransport(page, { server: serverB }) await page.route("**/*", async (route) => { const url = new URL(route.request().url()) if (url.origin !== serverA && url.origin !== serverB) return route.fallback() @@ -85,7 +88,6 @@ async function mockServers(page: Page, requests: string[]) { const current = url.origin === serverA ? sessionA : sessionB const directory = url.searchParams.get("directory") if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/api/event") return sse(route) if (url.pathname === "/api/health") return json(route, { pid: 1 }) if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} }) @@ -142,7 +144,3 @@ function json(route: Route, body: unknown, status = 200) { body: JSON.stringify(body), }) } - -function sse(route: Route) { - return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) -} diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index 3aea47fbf66..3fa4dbae7b2 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -13,6 +13,8 @@ const sessionB = session("ses_server_b", directoryB, "Server B session") test("session settings use the remote server context", async ({ page }) => { const permissionRequests: string[] = [] + await installSseTransport(page, { server: serverA }) + await installSseTransport(page, { server: serverB }) await mockServers(page, permissionRequests) await configureServers(page) @@ -46,6 +48,7 @@ test("session settings use the remote server context", async ({ page }) => { test("auto-accept responds for an unfocused server session", async ({ page }) => { const permissionRequests: string[] = [] const permissionResponses: PermissionResponse[] = [] + await installSseTransport(page, { server: serverB }) const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { server: serverA, retry: 20, @@ -181,7 +184,6 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR return json(route, true) } if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/api/event") return sse(route) if (url.pathname === "/api/provider") return json(route, { location: { directory }, @@ -325,7 +327,3 @@ function json(route: Route, body: unknown, status = 200) { body: JSON.stringify(body), }) } - -function sse(route: Route) { - return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) -} diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 5ea9d4f7613..5feb6670a3d 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -11,18 +11,23 @@ const title = "Request dock regression" test("shows a pending question dock", async ({ page }) => { await mockServer(page, { - questions: [ + forms: [ { - id: "question-request", + id: "frm_question_request", sessionID, - questions: [ + title: "Questions", + metadata: { kind: "question" }, + fields: [ { - header: "Implementation", - question: "Which implementation should be used?", + key: "q0", + type: "string", + title: "Implementation", + description: "Which implementation should be used?", options: [ - { label: "Minimal", description: "Use the smallest correct change" }, - { label: "Extended", description: "Include additional behavior" }, + { value: "minimal", label: "Minimal", description: "Use the smallest correct change" }, + { value: "extended", label: "Extended", description: "Include additional behavior" }, ], + custom: true, }, ], }, @@ -42,7 +47,7 @@ test("shows a pending question dock", async ({ page }) => { const rejectRequests: string[] = [] page.on("request", (request) => { if (request.method() !== "POST") return - if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`) + if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`) rejectRequests.push(request.url()) }) @@ -67,10 +72,10 @@ test("shows a pending question dock", async ({ page }) => { const reply = page.waitForRequest( (request) => request.method() === "POST" && - new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`, + new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/reply`, ) await question.getByRole("button", { name: "Submit" }).click() - expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) + expect((await reply).postDataJSON()).toEqual({ answer: { q0: "minimal" } }) }) test("shows a pending permission dock", async ({ page }) => { @@ -109,7 +114,7 @@ test("restores the draft caret before typing after a request dock closes", async server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, retry: 20, }) - await mockServer(page, { questions: [] }) + await mockServer(page, { forms: [] }) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await transport.waitForConnection() await expectSessionTitle(page, title) @@ -135,18 +140,24 @@ test("restores the draft caret before typing after a request dock closes", async await transport.send({ directory, payload: { - type: "question.asked", + type: "form.created", properties: { - id: "question-caret", - sessionID, - questions: [ - { - header: "Continue", - question: "Continue?", - options: [{ label: "Yes", description: "Continue the session" }], - }, - ], - tool: { messageID: "message-caret", callID: "call-caret" }, + form: { + id: "frm_question_caret", + sessionID, + title: "Questions", + metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } }, + fields: [ + { + key: "q0", + type: "string", + title: "Continue", + description: "Continue?", + options: [{ value: "yes", label: "Yes", description: "Continue the session" }], + custom: true, + }, + ], + }, }, }, }) @@ -156,7 +167,10 @@ test("restores the draft caret before typing after a request dock closes", async await transport.send({ directory, - payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } }, + payload: { + type: "form.cancelled", + properties: { sessionID, id: "frm_question_caret" }, + }, }) await expect(question).toHaveCount(0) await expect(editor).toBeVisible() @@ -170,6 +184,8 @@ async function mockServer( requests: { permissions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[]) + forms?: unknown[] | (() => unknown[]) + sessionStatus?: Record }, ) { await mockOpenCodeServer(page, { @@ -214,6 +230,8 @@ async function mockServer( pageMessages: () => ({ items: [] }), permissions: requests.permissions, questions: requests.questions, + forms: requests.forms, + sessionStatus: requests.sessionStatus, }) await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) diff --git a/packages/app/e2e/regression/session-timeline-notices.spec.ts b/packages/app/e2e/regression/session-timeline-notices.spec.ts new file mode 100644 index 00000000000..d8a9e0440d3 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-notices.spec.ts @@ -0,0 +1,188 @@ +import { expect, test } from "@playwright/test" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture" + +const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo + +const assistant = (completed: boolean, tool = false, childID?: string) => + ({ + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: tool + ? [ + { + type: "tool", + id: "call_subagent", + name: "subagent", + state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} }, + time: { created: 2 }, + }, + ] + : [{ type: "text", text: "Working" }], + time: { created: 2, ...(completed ? { completed: 3 } : {}) }, + }) satisfies SessionMessageInfo + +test("renders current protocol notices in CLI order", async ({ page }) => { + const ownerWarnings: string[] = [] + page.on("console", (message) => { + if (message.text().includes("computations created outside a `createRoot` or `render`")) + ownerWarnings.push(message.text()) + }) + await setupTimeline(page, { + currentMessages: [ + user, + { id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } }, + assistant(true), + { + id: "msg_subagent", + type: "synthetic", + text: "done", + description: "Search code", + metadata: { source: "subagent", agent: "explore", state: "completed" }, + time: { created: 4 }, + }, + { + id: "msg_restart", + type: "synthetic", + text: "continue", + description: "Continuing after restart", + time: { created: 5 }, + }, + { id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 6 } }, + ], + }) + + const notices = page.locator('[data-slot="session-timeline-notice"]') + await expect(notices).toHaveCount(4) + await expect(notices.nth(0)).toContainText("Agent · explore") + await expect(notices.nth(1)).toContainText("explore finished · Search code") + await expect(notices.nth(2)).toContainText("Continuing after restart") + await expect(notices.nth(3)).toContainText("Skill · Review") + expect(ownerWarnings).toEqual([]) +}) + +test("moves blocking work to the background with Ctrl+B", async ({ page }) => { + await setupTimeline(page, { currentMessages: [user, assistant(false, true)] }) + await expect(page.locator('[data-component="task-tool-card"]')).toBeVisible() + await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0) + await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0) + await expect(page.locator('[data-action="session-background-toggle"]')).toContainText( + "Move 1 subagent to background", + ) + + const request = page.waitForRequest( + (request) => + request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`, + ) + await page.keyboard.press("Control+b") + await request +}) + +test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => { + const childID = "ses_running_child" + await setupTimeline(page, { + currentMessages: [user, assistant(false, true, childID)], + sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })], + sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } }, + }) + + await expect(page.locator('[data-action="session-background-toggle"]')).toContainText( + "Move 1 subagent to background", + ) + await page.locator('[data-component="task-tool-card"]').click() + await expect(page).toHaveURL(new RegExp(`/session/${childID}$`)) + await expect(page.locator('[data-component="session-background-dock"]')).toHaveCount(0) +}) + +test("shows a badge for active background work", async ({ page }) => { + const childID = "ses_background_child" + await setupTimeline(page, { + currentMessages: [user, assistant(true)], + sessions: [session(), session({ id: childID, parentID: sessionID })], + sessionStatus: { [childID]: { type: "busy" } }, + }) + + await expect(page.locator('[data-component="session-background-dock"]')).toContainText("1 subagent in background") +}) + +test("separates blocking and already-backgrounded work into two rows", async ({ page }) => { + const backgroundID = "ses_background_existing" + const blockingID = "ses_background_blocking" + await setupTimeline(page, { + currentMessages: [ + user, + { + id: "msg_backgrounded", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { + type: "tool", + id: "call_backgrounded", + name: "subagent", + state: { + status: "completed", + input: { description: "Background task" }, + content: [{ type: "text", text: "working" }], + metadata: { sessionID: backgroundID, status: "running" }, + }, + time: { created: 2, completed: 3 }, + }, + { + type: "tool", + id: "call_shell_backgrounded", + name: "shell", + state: { + status: "completed", + input: { command: "sleep 120" }, + content: [{ type: "text", text: "working" }], + metadata: { shellID: "shell_backgrounded", status: "running" }, + }, + time: { created: 2, completed: 3 }, + }, + ], + time: { created: 2, completed: 3 }, + }, + { + id: "msg_blocking", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { + type: "tool", + id: "call_blocking", + name: "subagent", + state: { + status: "running", + input: { description: "Foreground task" }, + metadata: { sessionID: blockingID }, + }, + time: { created: 4 }, + }, + ], + time: { created: 4 }, + }, + ], + sessions: [ + session(), + session({ id: backgroundID, parentID: sessionID, title: "Background task" }), + session({ id: blockingID, parentID: sessionID, title: "Foreground task" }), + ], + sessionStatus: { + [sessionID]: { type: "busy" }, + [backgroundID]: { type: "busy" }, + [blockingID]: { type: "busy" }, + }, + }) + + const dock = page.locator('[data-component="session-background-dock"]') + await expect(dock).toContainText("Move 1 subagent to background") + await expect(dock.getByText("Running 1 shell and 1 subagent in background", { exact: true })).toBeVisible() + await expect( + page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'), + ).toHaveAttribute("data-active", "true") +}) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 93fb8a095a0..534767d4724 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -32,15 +32,110 @@ export interface MockServerConfig { todos?: (sessionID: string) => unknown[] permissions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[]) + forms?: unknown[] | (() => unknown[]) fileList?: (path: string) => unknown | Promise fileContent?: (path: string) => unknown | Promise findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown sessionStatus?: Record | (() => Record) } +type MockStreamWindow = Window & { + __testSseTransport?: unknown + __mockServerStream?: { push: (payloads: unknown[]) => void } +} + export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const cursors = new Map() let nextCursor = 0 + + await page.addInitScript( + ({ port, retry }) => { + const host = window as MockStreamWindow + if (host.__testSseTransport || host.__mockServerStream) return + const originalFetch = window.fetch.bind(window) + const encoder = new TextEncoder() + const state: { + controller?: ReadableStreamDefaultController + buffer: string[] + connections: number + } = { buffer: [], connections: 0 } + const frame = (payload: unknown) => `data: ${JSON.stringify(payload)}\n\n` + host.__mockServerStream = { + push(payloads: unknown[]) { + const frames = payloads.map(frame) + const controller = state.controller + if (!controller) { + state.buffer.push(...frames) + return + } + frames.forEach((item) => controller.enqueue(encoder.encode(item))) + }, + } + const fetch = (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + const url = new URL(request.url) + if (url.port !== port || url.pathname !== "/api/event") return originalFetch(request) + state.connections += 1 + const id = state.connections + let ended = false + let own: ReadableStreamDefaultController | undefined + const stream = new ReadableStream({ + start(controller) { + own = controller + state.controller = controller + if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) + controller.enqueue( + encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), + ) + state.buffer.splice(0).forEach((item) => controller.enqueue(encoder.encode(item))) + request.signal.addEventListener( + "abort", + () => { + if (ended) return + ended = true + if (state.controller === controller) state.controller = undefined + controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError")) + }, + { once: true }, + ) + }, + cancel() { + if (ended) return + ended = true + if (state.controller === own) state.controller = undefined + }, + }) + return Promise.resolve( + new Response(stream, { + status: 200, + headers: { "cache-control": "no-cache", "content-type": "text/event-stream" }, + }), + ) + } + Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch }) + }, + { port: process.env.PLAYWRIGHT_SERVER_PORT ?? "4096", retry: config.eventRetry }, + ) + + if (config.events) { + const pump = { busy: false } + const timer = setInterval(() => { + if (pump.busy) return + const batch = config.events?.() ?? [] + if (batch.length === 0) return + pump.busy = true + void page + .evaluate( + (payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads), + batch.map(currentEvent), + ) + .catch(() => {}) + .finally(() => { + pump.busy = false + }) + }, 50) + page.on("close", () => clearInterval(timer)) + } const staticRoutes: Record = { "/path": { state: config.directory, @@ -195,6 +290,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { location: location(config), data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []), }) + if (path === "/api/form/request") + return json(route, { + location: location(config), + data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []), + }) if (path === "/api/vcs") return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } }) if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] }) @@ -280,6 +380,21 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) } + const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1] + if (sessionForm && route.request().method() === "GET") { + const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? []) + return json( + route, + { data: forms.filter((form) => (form as { sessionID?: string }).sessionID === sessionForm) }, + ) + } + if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST") + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET") + return json(route, { data: [] }) if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) } diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index b859a01efba..0b5e7092b57 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -54,7 +54,7 @@ import { useCheckServerHealth } from "./utils/server-health" import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" import { decode64 } from "@/utils/base64" -import { TargetSessionRouteContent } from "@/pages/session" +import { SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" import { Home } from "@/pages/home" const NewSession = lazy(() => import("@/pages/new-session")) @@ -93,11 +93,16 @@ function TargetServerRoute(props: ParentProps) { ) } -const TargetSessionRoute = () => ( - - - -) +function TargetSessionRoute() { + const params = useParams<{ serverKey: string; id: string }>() + return ( + + + + + + ) +} // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected // server via ServerKey, then provide the server-scoped shell for that server. @@ -448,12 +453,10 @@ export function AppInterface(props: { // route changes. Draft and session routes override only their server-bound data // providers beneath it. const ServerShell = (shellProps: ParentProps) => ( - - - {props.children} - {shellProps.children} - - + + {props.children} + {shellProps.children} + ) return ( diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index f4aa85fb2bd..d4ed7ee6211 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -20,6 +20,7 @@ import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { popularProviders, useProviders } from "@/hooks/use-providers" +import { useIntegrations } from "@/hooks/use-integrations" import { CustomProviderForm } from "./dialog-custom-provider" import { decode64 } from "@/utils/base64" import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller" @@ -140,7 +141,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string const settings = useSettings() if (settings.general.newLayoutDesigns()) return - const providers = useProviders(() => props.directory) + const integrations = useIntegrations(() => props.directory) const language = useLanguage() const popularGroup = () => language.t("dialog.provider.group.popular") const otherGroup = () => language.t("dialog.provider.group.other") @@ -162,7 +163,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string key={(x) => x?.id} items={() => { language.locale() - return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()] + return [{ id: CUSTOM_ID, name: customLabel() }, ...integrations.list()] }} filterKeys={["id", "name"]} groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} @@ -208,7 +209,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string } function ProviderPickerV2(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) { - const providers = useProviders(() => props.directory) + const integrations = useIntegrations(() => props.directory) const language = useLanguage() const [store, setStore] = createStore({ filter: "", @@ -220,7 +221,7 @@ function ProviderPickerV2(props: { directory?: string; onSelect: (provider: stri const all = createMemo(() => { language.locale() const query = store.filter.trim().toLowerCase() - const values = [custom(), ...providers.all().values()] + const values = [custom(), ...integrations.list()] if (!query) return values return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query)) }) @@ -369,9 +370,6 @@ function ProviderConnection(props: { const providers = useProviders(() => props.directory) const directory = () => props.directory ?? decode64(params.dir) - const provider = createMemo( - () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, - ) const controller = createProviderConnectionController({ provider: () => props.provider, directory, @@ -385,6 +383,16 @@ function ProviderConnection(props: { }) }, }) + const provider = createMemo( + () => ({ + id: props.provider, + name: + providers.all().get(props.provider)?.name ?? + serverSync().data.provider.all.get(props.provider)?.name ?? + controller.integration()?.name ?? + props.provider, + }), + ) const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" if (value.type === "key") return language.t("provider.connect.method.apiKey") diff --git a/packages/app/src/components/dialog-select-model-unpaid-v2.tsx b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx index 422d2c76f11..e63ee35a800 100644 --- a/packages/app/src/components/dialog-select-model-unpaid-v2.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx @@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme } from "@opencode-ai/ui/theme" import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js" import { useLocal } from "@/context/local" -import { useProviders } from "@/hooks/use-providers" +import { useIntegrations } from "@/hooks/use-integrations" import { decode64 } from "@/utils/base64" import { useLanguage } from "@/context/language" import { ModelTooltip } from "./model-tooltip" @@ -22,7 +22,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro const dialog = useDialog() const theme = useTheme() const directory = () => decode64(local.slug()) - const providers = useProviders(directory) + const integrations = useIntegrations(directory) const language = useLanguage() const modelKey = (item: ReturnType[number]) => `${item.provider.id}:${item.id}` const currentKey = createMemo(() => { @@ -127,7 +127,8 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
featuredProviders.includes(provider.id)) .sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))} > diff --git a/packages/app/src/components/provider-connection-controller.ts b/packages/app/src/components/provider-connection-controller.ts index 680635a3657..a9136fa3c8b 100644 --- a/packages/app/src/components/provider-connection-controller.ts +++ b/packages/app/src/components/provider-connection-controller.ts @@ -238,6 +238,7 @@ export function createProviderConnectionController(options: { return { loading: () => integration.loading, + integration: () => integration.latest, methods, currentMethod, methodIndex: () => store.methodIndex, diff --git a/packages/app/src/components/settings-server-picker.tsx b/packages/app/src/components/settings-server-picker.tsx index b73ad0d23cd..6c4e809d587 100644 --- a/packages/app/src/components/settings-server-picker.tsx +++ b/packages/app/src/components/settings-server-picker.tsx @@ -1,4 +1,3 @@ -import { QueryClientProvider } from "@tanstack/solid-query" import { type ParentProps, Show } from "solid-js" import { useGlobal } from "@/context/global" import { ModelsProvider } from "@/context/models" @@ -6,25 +5,25 @@ import { ServerConnection } from "@/context/server" import { ServerSDKProvider } from "@/context/server-sdk" import { ServerSyncProvider } from "@/context/server-sync" -export function SettingsServerScope(props: ParentProps) { +export function SettingsServerScope(props: ParentProps<{ directory?: string }>) { const global = useGlobal() return ( - {(server) => {props.children}} + {(server) => ( + + {props.children} + + )} ) } -export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any }>) { - const global = useGlobal() - const serverCtx = () => global.ensureServerCtx(props.server) +export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) { return ( - - - - {props.children} - - - + + + {props.children} + + ) } diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index fc2230bbc76..f350fc8279b 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -139,7 +139,7 @@ export const DialogSettings: Component<{ - + diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index 496b5ff8bd4..0615000bcd8 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -45,7 +45,7 @@ export const SettingsProvidersV2: Component<{ const connect = (provider?: string) => { providerConnect.select(provider) void dialog.show(() => ( - + )) @@ -240,7 +240,7 @@ export const SettingsProvidersV2: Component<{ icon="plus" onClick={() => { dialog.show(() => ( - + )) diff --git a/packages/app/src/context/file/watcher.test.ts b/packages/app/src/context/file/watcher.test.ts index 9536b52536b..dbe745ff7d9 100644 --- a/packages/app/src/context/file/watcher.test.ts +++ b/packages/app/src/context/file/watcher.test.ts @@ -7,7 +7,7 @@ describe("file watcher invalidation", () => { const refresh: string[] = [] invalidateFromWatcher( { - type: "file.watcher.updated", + type: "filesystem.changed", properties: { file: "src/new.ts", event: "add", @@ -32,7 +32,7 @@ describe("file watcher invalidation", () => { invalidateFromWatcher( { - type: "file.watcher.updated", + type: "filesystem.changed", properties: { file: "src/open.ts", event: "change", @@ -63,7 +63,7 @@ describe("file watcher invalidation", () => { invalidateFromWatcher( { - type: "file.watcher.updated", + type: "filesystem.changed", properties: { file: "src", event: "change", @@ -81,7 +81,7 @@ describe("file watcher invalidation", () => { invalidateFromWatcher( { - type: "file.watcher.updated", + type: "filesystem.changed", properties: { file: "src/file.ts", event: "change", @@ -111,7 +111,7 @@ describe("file watcher invalidation", () => { invalidateFromWatcher( { - type: "file.watcher.updated", + type: "filesystem.changed", properties: { file: ".git/index.lock", event: "change", diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts index 79cda021956..3b58d31b839 100644 --- a/packages/app/src/context/file/watcher.ts +++ b/packages/app/src/context/file/watcher.ts @@ -16,7 +16,7 @@ type WatcherOps = { } export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { - if (event.type !== "file.watcher.updated") return + if (event.type !== "filesystem.changed") return const props = typeof event.properties === "object" && event.properties ? (event.properties as Record) : undefined const rawPath = typeof props?.file === "string" ? props.file : undefined diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 5e09ca57cf3..96ddca390c4 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -6,6 +6,8 @@ import type { CommandInfo, CommandListInput, CommandListOutput, + IntegrationListInput, + IntegrationListOutput, LocationGetInput, LocationGetOutput, PermissionRequest, @@ -24,9 +26,9 @@ import { getFilename } from "@opencode-ai/core/util/path" import { retry } from "@opencode-ai/core/util/retry" import { batch } from "solid-js" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" -import type { State, VcsCache } from "./types" +import type { State } from "./types" import type { ServerSession } from "../server-session" -import { cmp, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils" +import { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" import { QueryClient, queryOptions } from "@tanstack/solid-query" import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" @@ -221,6 +223,10 @@ type CommandListApi = { readonly list: (input?: CommandListInput) => Promise } +type IntegrationListApi = { + readonly list: (input?: IntegrationListInput) => Promise +} + type ReferenceListApi = { readonly list: (input?: ReferenceListInput) => Promise } @@ -231,6 +237,13 @@ export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: Agen queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))), }) +export const loadIntegrationsQuery = (scope: ServerScope, directory: string | null, sdk: IntegrationListApi) => + queryOptions({ + queryKey: [scope, directory, "integrations"] as const, + queryFn: () => + retry(() => sdk.list(directory ? { location: { directory } } : undefined).then((result) => result.data)), + }) + export const loadCommands = (directory: string, api: CommandListApi): Promise => retry(() => api.list({ location: { directory } }).then((result) => result.data)) @@ -272,7 +285,6 @@ export async function bootstrapDirectory(input: { } store: Store setStore: SetStoreFunction - vcsCache: VcsCache loadSessions: (directory: string) => Promise | void translate: (key: string, vars?: Record) => string global: { @@ -297,104 +309,109 @@ export async function bootstrapDirectory(input: { const revKey = ScopedKey.from(input.scope, input.directory) const rev = (providerRev.get(revKey) ?? 0) + 1 providerRev.set(revKey, rev) - ;(async () => { - const slow = [ - () => Promise.resolve(input.loadSessions(input.directory)), - () => + const slow = [ + () => Promise.resolve(input.loadSessions(input.directory)), + () => + input.queryClient + .ensureQueryData(loadAgentsQuery(input.scope, directoryKey(input.directory), input.api.agent)) + .then((data) => input.setStore("agent", data)), + !seededProject && + (() => + retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) => + input.setStore("project", project.id), + )), + !seededPath && + (() => input.queryClient - .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent)) - .then((data) => input.setStore("agent", data)), - !seededProject && - (() => - retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) => - input.setStore("project", project.id), - )), - !seededPath && - (() => - input.queryClient - .ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location)) - .then((data) => { - const next = projectID(data.directory ?? input.directory, input.global.project) - if (next) input.setStore("project", next) - })), - input.mcp && - (() => - loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))), - () => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.api.reference)), - () => - retry(() => - input.api.permission.request - .list({ location: { directory: input.directory } }) - .then((result) => result.data) - .then((permissions) => { - const ids = permissions.map((permission) => permission.sessionID) - const grouped = groupBySession( - permissions.filter((permission) => !!permission.id && !!permission.sessionID), - ) - const warm = input.session - ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) - : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) - return warm.then(() => - batch(() => { - const current = input.session?.data.permission ?? input.store.permission - for (const sessionID of Object.keys(current)) { - if (grouped[sessionID]) continue - if (input.session?.get(sessionID)?.location.directory !== input.directory) continue - if (input.session) input.session.set("permission", sessionID, []) - if (!input.session) input.setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - const value = reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ) - if (input.session) input.session.set("permission", sessionID, value) - if (!input.session) input.setStore("permission", sessionID, value) - } - }), - ) - }), - ), - () => - retry(() => - input.api.question.request - .list({ location: { directory: input.directory } }) - .then((result) => result.data) - .then((questions) => { - const ids = questions.map((question) => question.sessionID) - const grouped = groupBySession( - questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[], - ) - const warm = input.session - ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) - : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) - return warm.then(() => - batch(() => { - const current = input.session?.data.question ?? input.store.question - for (const sessionID of Object.keys(current)) { - if (grouped[sessionID]) continue - if (input.session?.get(sessionID)?.location.directory !== input.directory) continue - if (input.session) input.session.set("question", sessionID, []) - if (!input.session) input.setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - const value = reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ) - if (input.session) input.session.set("question", sessionID, value) - if (!input.session) input.setStore("question", sessionID, value) - } - }), - ) - }), - ), - () => Promise.resolve(input.loadSessions(input.directory)), - input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))), - input.mcp && - (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))), - () => - input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api)).catch((err) => { + .ensureQueryData(loadPathQuery(input.scope, directoryKey(input.directory), input.api.location)) + .then((data) => { + const next = projectID(data.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + })), + input.mcp && + (() => loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))), + () => + input.queryClient.fetchQuery( + loadReferencesQuery(input.scope, directoryKey(input.directory), input.api.reference), + ), + () => + retry(() => + input.api.permission.request + .list({ location: { directory: input.directory } }) + .then((result) => result.data) + .then((permissions) => { + const ids = permissions.map((permission) => permission.sessionID) + const grouped = groupBySession( + permissions.filter((permission) => !!permission.id && !!permission.sessionID), + ) + const warm = input.session + ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) + : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) + return warm.then(() => + batch(() => { + const current = input.session?.data.permission ?? input.store.permission + for (const sessionID of Object.keys(current)) { + if (grouped[sessionID]) continue + if (input.session?.get(sessionID)?.location.directory !== input.directory) continue + if (input.session) input.session.set("permission", sessionID, []) + if (!input.session) input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + const value = reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ) + if (input.session) input.session.set("permission", sessionID, value) + if (!input.session) input.setStore("permission", sessionID, value) + } + }), + ) + }), + ), + () => + retry(() => + input.api.question.request + .list({ location: { directory: input.directory } }) + .then((result) => result.data) + .then((questions) => { + const ids = questions.map((question) => question.sessionID) + const grouped = groupBySession( + questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[], + ) + const warm = input.session + ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) + : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) + return warm.then(() => + batch(() => { + const current = input.session?.data.question ?? input.store.question + for (const sessionID of Object.keys(current)) { + if (grouped[sessionID]) continue + if (input.session?.get(sessionID)?.location.directory !== input.directory) continue + if (input.session) input.session.set("question", sessionID, []) + if (!input.session) input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + const value = reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ) + if (input.session) input.session.set("question", sessionID, value) + if (!input.session) input.setStore("question", sessionID, value) + } + }), + ) + }), + ), + () => Promise.resolve(input.loadSessions(input.directory)), + input.mcp && + (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))), + input.mcp && + (() => + input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, directoryKey(input.directory), input.api.mcp))), + () => + input.queryClient + .fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api)) + .catch((err) => { const project = getFilename(input.directory) showToast({ variant: "error", @@ -402,20 +419,19 @@ export async function bootstrapDirectory(input: { description: formatServerError(err, input.translate), }) }), - ].filter(Boolean) as (() => Promise)[] + ].filter(Boolean) as (() => Promise)[] - await waitForPaint() - const slowErrs = errors(await runAll(slow)) - if (slowErrs.length > 0) { - console.error("Failed to finish bootstrap instance", slowErrs[0]) - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(slowErrs[0], input.translate), - }) - } + await waitForPaint() + const slowErrs = errors(await runAll(slow)) + if (slowErrs.length > 0) { + console.error("Failed to finish bootstrap instance", slowErrs[0]) + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(slowErrs[0], input.translate), + }) + } - if (loading && slowErrs.length === 0) input.setStore("status", "complete") - })() + if (loading && slowErrs.length === 0) input.setStore("status", "complete") } diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index e05b7c39a96..30a822cdbbc 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -8,6 +8,7 @@ import { ServerScope } from "@/utils/server-scope" let createChildStoreManager: typeof import("./child-store").createChildStoreManager const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = [] +let providerQuerySuccess = true const persist: typeof import("@/utils/persist").persisted = (_target, store) => [ store[0], store[1], @@ -58,11 +59,17 @@ beforeAll(async () => { get isLoading() { return options().queryKey?.[1] === "path" }, + get isSuccess() { + return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess + }, + get isRefetchError() { + return false + }, get data() { if (options().queryKey?.[1] === "path") throw new Error("pending path data read") if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined if (options().queryKey?.[1] === "lsp") return [] - if (options().queryKey?.[1] === "providers") return provider + if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined return undefined }, } @@ -83,6 +90,7 @@ describe("createChildStoreManager", () => { const manager = createChildStoreManager({ owner, + connected: () => true, scope: ServerScope.local, persist, isBooting: () => false, @@ -114,6 +122,7 @@ describe("createChildStoreManager", () => { const dispose = createOwner((owner) => { manager = createChildStoreManager({ owner, + connected: () => true, scope: ServerScope.local, persist, isBooting: () => false, @@ -148,6 +157,7 @@ describe("createChildStoreManager", () => { const dispose = createOwner((owner) => { manager = createChildStoreManager({ owner, + connected: () => true, scope: ServerScope.local, persist, isBooting: () => false, @@ -173,6 +183,37 @@ describe("createChildStoreManager", () => { } }) + test("writes refreshed VCS data to the child store", () => { + let manager: ReturnType | undefined + const dispose = createOwner((owner) => { + manager = createChildStoreManager({ + owner, + connected: () => true, + scope: ServerScope.local, + persist, + isBooting: () => false, + isLoadingSessions: () => false, + onBootstrap() {}, + onMcp() {}, + onDispose() {}, + translate: (key) => key, + queryOptions: queryOptionsApi, + global: { provider }, + }) + }) + + try { + if (!manager) throw new Error("manager required") + const [store] = manager.child("/project", { bootstrap: false }) + + manager.vcs("/project", { branch: "feature", default_branch: "main" }) + + expect(store.vcs).toEqual({ branch: "feature", default_branch: "main" }) + } finally { + dispose() + } + }) + test("enables MCP only when requested for the directory", () => { let manager: ReturnType | undefined const offset = querySingles.length @@ -181,6 +222,7 @@ describe("createChildStoreManager", () => { const dispose = createOwner((owner) => { manager = createChildStoreManager({ owner, + connected: () => true, scope: ServerScope.local, persist, isBooting: () => false, @@ -230,6 +272,7 @@ describe("createChildStoreManager", () => { const dispose = createOwner((owner) => { manager = createChildStoreManager({ owner, + connected: () => true, scope: ServerScope.local, persist, isBooting: () => false, @@ -273,4 +316,71 @@ describe("createChildStoreManager", () => { dispose() } }) + + test("does not mark a cancelled provider query as ready", () => { + let manager: ReturnType | undefined + providerQuerySuccess = false + + const dispose = createOwner((owner) => { + manager = createChildStoreManager({ + owner, + connected: () => true, + scope: ServerScope.local, + persist, + isBooting: () => false, + isLoadingSessions: () => false, + onBootstrap() {}, + onMcp() {}, + onDispose() {}, + translate: (key) => key, + queryOptions: queryOptionsApi, + global: { provider }, + }) + }) + + try { + if (!manager) throw new Error("manager required") + const [store] = manager.child("/cancelled") + expect(store.provider_ready).toBe(false) + } finally { + providerQuerySuccess = true + dispose() + } + }) + + test("does not enable location queries before the event handshake", () => { + let manager: ReturnType | undefined + let connected = false + const offset = querySingles.length + const dispose = createOwner((owner) => { + manager = createChildStoreManager({ + owner, + connected: () => connected, + scope: ServerScope.local, + persist, + isBooting: () => false, + isLoadingSessions: () => false, + onBootstrap() {}, + onMcp() {}, + onDispose() {}, + translate: (key) => key, + queryOptions: queryOptionsApi, + global: { provider }, + }) + }) + + try { + if (!manager) throw new Error("manager required") + manager.child("/handshake") + const queries = querySingles.slice(offset) + expect(queries[0]?.().enabled).toBe(false) + expect(queries[4]?.().enabled).toBe(false) + + connected = true + expect(queries[0]?.().enabled).toBe(true) + expect(queries[4]?.().enabled).toBe(true) + } finally { + dispose() + } + }) }) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index d376aa805e0..2149857baa1 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,4 +1,4 @@ -import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" +import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" import type { VcsInfo } from "@/types" @@ -22,6 +22,7 @@ import type { ServerScope } from "@/utils/server-scope" export function createChildStoreManager(input: { owner: Owner + connected: Accessor scope: ServerScope persist: typeof persisted isBooting: (directory: string) => boolean @@ -188,17 +189,29 @@ export function createChildStoreManager(input: { const [mcpEnabled, setMcpEnabled] = createSignal(false) const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false) - const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() })) - const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() })) - const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() })) - const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() })) + const pathQuery = useQuery(() => ({ + ...input.queryOptions.path(key), + enabled: input.connected() && instanceQueriesEnabled(), + })) + const mcpQuery = useQuery(() => ({ + ...input.queryOptions.mcp(key), + enabled: input.connected() && mcpEnabled(), + })) + const mcpResourceQuery = useQuery(() => ({ + ...input.queryOptions.mcpResources(key), + enabled: input.connected() && mcpEnabled(), + })) + const lspQuery = useQuery(() => ({ + ...input.queryOptions.lsp(key), + enabled: input.connected() && instanceQueriesEnabled(), + })) const providerQuery = useQuery(() => ({ ...input.queryOptions.providers(key), - enabled: instanceQueriesEnabled(), + enabled: input.connected() && instanceQueriesEnabled(), })) const referenceQuery = useQuery(() => ({ ...input.queryOptions.references(key), - enabled: instanceQueriesEnabled(), + enabled: input.connected() && instanceQueriesEnabled(), })) const child = createStore({ @@ -206,13 +219,14 @@ export function createChildStoreManager(input: { projectMeta: initialMeta, icon: initialIcon, get provider_ready() { - return instanceQueriesEnabled() && !providerQuery.isLoading + return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError) }, get provider() { const EMPTY = { all: new Map(), connected: [], default: {} } - if (providerQuery.isLoading) return EMPTY - if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider - return providerQuery.data ?? EMPTY + if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY + const provider = providerQuery.data + if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider + return provider }, config: {}, get path() { @@ -373,6 +387,15 @@ export function createChildStoreManager(input: { setStore("icon", value) } + function vcs(directory: string, value: VcsInfo) { + const key = directoryKey(directory) + const child = ensureChild(directory) + const cached = vcsCache.get(key) + if (!cached) return + cached.setStore("value", value) + child[1]("vcs", value) + } + return { children, ensureChild, @@ -380,6 +403,7 @@ export function createChildStoreManager(input: { peek, projectMeta, projectIcon, + vcs, mark, pin, unpin, diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index 61cd358bb95..804fa2e354e 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -120,7 +120,7 @@ describe("applyGlobalEvent", () => { expect(refreshCount).toBe(1) }) - test("handles server.connected by triggering refresh", () => { + test("leaves server.connected refresh to the connection sync", () => { let refreshCount = 0 applyGlobalEvent({ event: { type: "server.connected" }, @@ -131,7 +131,7 @@ describe("applyGlobalEvent", () => { setGlobalProject() {}, }) - expect(refreshCount).toBe(1) + expect(refreshCount).toBe(0) }) }) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index c8437a78dcf..98ac96342ca 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -38,11 +38,10 @@ export function applyGlobalEvent(input: { setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void refresh: () => void }) { - if (input.event.type === "global.disposed" || input.event.type === "server.connected") { + if (input.event.type === "global.disposed") { input.refresh() return } - if (input.event.type !== "project.updated") return const properties = input.event.properties as Project const result = Binary.search(input.project, properties.id, (s) => s.id) diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index b99c9906470..28e21056dd9 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { Message, Part, Todo } from "@/types" -import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" +import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" @@ -34,6 +34,7 @@ describe("app session cache", () => { part: Record permission: Record question: Record + form: Record part_text_accum_delta: Record } = { session_status: { ses_1: { type: "busy" } as SessionStatus }, @@ -44,6 +45,7 @@ describe("app session cache", () => { part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, permission: { ses_1: [] as PermissionRequest[] }, question: { ses_1: [] as QuestionRequest[] }, + form: { ses_1: [] as FormInfo[] }, part_text_accum_delta: { prt_1: "streamed text" }, } @@ -57,6 +59,7 @@ describe("app session cache", () => { expect(store.session_status.ses_1).toBeUndefined() expect(store.permission.ses_1).toBeUndefined() expect(store.question.ses_1).toBeUndefined() + expect(store.form.ses_1).toBeUndefined() }) test("dropSessionCaches clears message-backed parts", () => { @@ -70,6 +73,7 @@ describe("app session cache", () => { part: Record permission: Record question: Record + form: Record part_text_accum_delta: Record } = { session_status: {}, @@ -80,6 +84,7 @@ describe("app session cache", () => { part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, permission: {}, question: {}, + form: {}, part_text_accum_delta: {}, } diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 1231e96d93b..9187bd9a45c 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -1,5 +1,5 @@ import type { Message, Part, Todo } from "@/types" -import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" +import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { SessionMessageInfo } from "@opencode-ai/client/promise" @@ -14,6 +14,7 @@ type SessionCache = { part: Record permission: Record question: Record + form?: Record part_text_accum_delta: Record } @@ -38,6 +39,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable, ) { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnReconnect: false, - refetchOnMount: false, - refetchOnWindowFocus: false, - }, - }, - }) const sdk = createServerSdkContext(conn, scope) const sync = createServerSyncContext(sdk) @@ -141,7 +131,6 @@ function createServerCtx( (conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url)) return { - queryClient, sdk, sync, isLocal, diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index e387391f68b..380c4808d79 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -33,6 +33,8 @@ type PluralKey = | "session.question.pending" | "session.followupDock.summary" | "session.revertDock.summary" + | "session.background.shell" + | "session.background.subagent" type Source = { dict: Record } function cookie(locale: Locale) { diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index cf2ea7bd841..7dbf014f781 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -398,15 +398,20 @@ function createServerNotificationState(input: { const unsub = serverSDK().event.listen((e) => { const event = e.details - if (event.type !== "session.idle" && event.type !== "session.execution.failed") return + if ( + event.type !== "session.execution.succeeded" && + event.type !== "session.execution.interrupted" && + event.type !== "session.execution.failed" + ) + return const directory = e.name const time = Date.now() - if (event.type === "session.idle") { - handleSessionIdle(directory, event, time) + if (event.type === "session.execution.failed") { + handleSessionError(directory, event, time) return } - handleSessionError(directory, event, time) + handleSessionIdle(directory, event, time) }) onCleanup(() => { meta.disposed = true diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index c339c37aa13..ff33d39b555 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -3,7 +3,8 @@ import type { Event } from "@/types" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" -import { batch, createMemo, onCleanup, onMount } from "solid-js" +import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" import { createApiForServer, type ServerApi } from "@/utils/server" import { useLanguage } from "./language" import { usePlatform } from "./platform" @@ -12,10 +13,6 @@ import { createRefCountMap } from "@/utils/refcount" import { useGlobal } from "./global" import { ServerScope } from "@/utils/server-scope" -const isAbortError = (error: unknown) => - error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" - -const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent } type QueuedServerEvent = { directory: string; payload: ServerEvent } type CurrentDelta = Extract< @@ -95,15 +92,20 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () } type ServerEventEmitter = ReturnType> +export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting" type ServerSDKBase = { server: ServerConnection.Any scope: ServerScope url: string api: ServerApi + connection: { + status: Accessor + attempt: Accessor + error: Accessor + } event: { on: ServerEventEmitter["on"] listen: ServerEventEmitter["listen"] - start: () => Promise | undefined } } @@ -130,14 +132,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS type Queued = QueuedServerEvent const FLUSH_FRAME_MS = 16 const STREAM_YIELD_MS = 8 - const RECONNECT_DELAY_MS = 250 + const CONNECT_TIMEOUT_MS = 2_000 + const RECONNECT_DELAY_MS = 1_000 let queue: Queued[] = [] let buffer: Queued[] = [] let timer: ReturnType | undefined let last = 0 - const flush = () => { + function flush() { if (timer) clearTimeout(timer) timer = undefined @@ -157,63 +160,132 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS buffer.length = 0 } - const schedule = () => { + function schedule() { if (timer) return const elapsed = Date.now() - last timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed)) } - let streamErrorLogged = false - const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + function publish(event: OpenCodeEvent) { + const directory = event.location?.directory ?? "global" + if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule() + } + + function wait(delay: number, signal: AbortSignal) { + return new Promise((resolve) => { + const timer = setTimeout(done, delay) + signal.addEventListener("abort", done, { once: true }) + function done() { + clearTimeout(timer) + signal.removeEventListener("abort", done) + resolve() + } + }) + } let attempt: AbortController | undefined let run: Promise | undefined let started = false let generation = 0 + const [connection, setConnection] = createStore<{ + status: ServerConnectionStatus + attempt: number + error?: string + }>({ status: "connecting", attempt: 0 }) - const start = () => { + async function connect(signal: AbortSignal): Promise<{ error: unknown; connectedAt: number | undefined }> { + let connectedAt: number | undefined + + // Bound the initial handshake and tie this request to the stream lifetime. + const request = new AbortController() + const cancel = () => request.abort(signal.reason) + const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), CONNECT_TIMEOUT_MS) + signal.addEventListener("abort", cancel, { once: true }) + + try { + // Open the event stream and validate its initial handshake. + const iterator = eventApi.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]() + const first = await iterator.next() + + if (signal.aborted) return { error: undefined, connectedAt } + if (first.done) { + const error = + request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected") + return { error, connectedAt } + } + if (first.value.type !== "server.connected") + return { error: new Error("Event stream did not start with server.connected"), connectedAt } + + // Publish the connected state before forwarding live events. + clearTimeout(timeout) + publish(first.value) + connectedAt = Date.now() + setConnection({ status: "connected", attempt: 0, error: undefined }) + + // Forward events until the stream closes or this connection is cancelled. + let yielded = Date.now() + while (!signal.aborted) { + const event = await iterator.next() + if (signal.aborted) return { error: undefined, connectedAt } + if (event.done) return { error: new Error("Event stream disconnected"), connectedAt } + publish(event.value) + if (Date.now() - yielded < STREAM_YIELD_MS) continue + yielded = Date.now() + await wait(0, signal) + } + return { error: undefined, connectedAt } + } catch (error) { + return { error, connectedAt } + } finally { + request.abort() + clearTimeout(timeout) + signal.removeEventListener("abort", cancel) + } + } + + async function runStream(active: number) { + let retries = 0 + // oxlint-disable-next-line no-unmodified-loop-condition -- stop() changes the lifecycle flags and aborts the active request + while (!abort.signal.aborted && started && generation === active) { + setConnection({ status: retries === 0 ? "connecting" : "reconnecting", attempt: retries, error: undefined }) + const controller = new AbortController() + attempt = controller + const onAbort = () => controller.abort() + abort.signal.addEventListener("abort", onAbort) + const result = await connect(controller.signal) + abort.signal.removeEventListener("abort", onAbort) + + if (abort.signal.aborted || !started || generation !== active) { + if (attempt === controller) attempt = undefined + return + } + if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) retries = 0 + retries += 1 + const message = + result.error === undefined + ? undefined + : result.error instanceof Error + ? result.error.message + : String(result.error) + console.info("[global-sdk] event stream disconnected", { + url: server.http.url, + fetch: eventFetch ? "platform" : "webview", + attempt: retries, + error: message, + }) + setConnection({ status: "reconnecting", attempt: retries, error: message }) + await wait(RECONNECT_DELAY_MS, controller.signal) + if (attempt === controller) attempt = undefined + } + } + + function start() { if (started) return run started = true const active = ++generation const previous = run const current = (async () => { if (previous) await previous - // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit - while (!abort.signal.aborted && started && generation === active) { - attempt = new AbortController() - const onAbort = () => { - attempt?.abort() - } - abort.signal.addEventListener("abort", onAbort) - try { - const events = eventApi.event.subscribe({ signal: attempt.signal }) - let yielded = Date.now() - for await (const event of events) { - streamErrorLogged = false - const directory = event.location?.directory ?? "global" - const payload = adaptServerEvent(event) - if (enqueueServerEvent(queue, { directory, payload })) schedule() - - if (Date.now() - yielded < STREAM_YIELD_MS) continue - yielded = Date.now() - await wait(0) - } - } catch (error) { - if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) { - streamErrorLogged = true - console.error("[global-sdk] event stream failed", { - url: server.http.url, - fetch: eventFetch ? "platform" : "webview", - error, - }) - } - } finally { - abort.signal.removeEventListener("abort", onAbort) - attempt = undefined - } - - if (abort.signal.aborted || !started || generation !== active) return - await wait(RECONNECT_DELAY_MS) - } + await runStream(active) })().finally(() => { if (run !== current) return run = undefined @@ -223,7 +295,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS return run } - const stop = () => { + function stop() { started = false generation++ attempt?.abort() @@ -232,12 +304,17 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS onMount(() => { makeEventListener(window, "pagehide", stop) makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start)) + void start() }) onCleanup(() => { stop() abort.abort() - flush() + if (timer) clearTimeout(timer) + timer = undefined + queue = [] + buffer = [] + emitter.clear() }) const api = createApiForServer({ server: server.http, fetch: platform.fetch }) @@ -247,10 +324,14 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS scope, url: server.http.url, api, + connection: { + status: () => connection.status, + attempt: () => connection.attempt, + error: () => connection.error, + }, event: { on: emitter.on.bind(emitter), listen: emitter.listen.bind(emitter), - start, }, } } diff --git a/packages/app/src/context/server-session-v2-reducer.test.ts b/packages/app/src/context/server-session-v2-reducer.test.ts index 358909cad0a..0db44cb95d6 100644 --- a/packages/app/src/context/server-session-v2-reducer.test.ts +++ b/packages/app/src/context/server-session-v2-reducer.test.ts @@ -211,6 +211,58 @@ describe("v2 session reducer", () => { expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] }) }) + test("projects rendered instruction updates", () => { + const reducer = createV2SessionReducer() + const result = reducer.reduce( + [], + event({ + ...base, + id: "evt_instructions", + type: "session.instructions.updated", + data: { sessionID: "ses_1", delta: { agents: "hash" }, text: "Changed instructions" }, + }), + ) + + expect(result?.messages).toEqual([ + { + id: "msg_instructions", + type: "system", + text: "Changed instructions", + description: "Instructions updated: agents", + metadata: undefined, + time: { created: 1 }, + }, + ]) + }) + + test("projects session movement with the previous location", () => { + const result = createV2SessionReducer().reduce( + [], + event({ + ...base, + id: "evt_moved", + type: "session.moved", + data: { + sessionID: "ses_1", + projectID: "project_2", + location: { directory: "/repo-2" }, + subpath: "packages/app", + }, + }), + { projectID: "project_1", location: { directory: "/repo-1" } }, + ) + + expect(result?.messages).toMatchObject([ + { + id: "msg_moved", + type: "location-switched", + projectID: "project_2", + location: { directory: "/repo-2" }, + previous: { projectID: "project_1", location: { directory: "/repo-1" } }, + }, + ]) + }) + test("removes cancelled input from the pending promotion fold", () => { const reducer = createV2SessionReducer() reducer.reduce( diff --git a/packages/app/src/context/server-session-v2-reducer.ts b/packages/app/src/context/server-session-v2-reducer.ts index ad04bb6e4ef..b03d1a1e0ee 100644 --- a/packages/app/src/context/server-session-v2-reducer.ts +++ b/packages/app/src/context/server-session-v2-reducer.ts @@ -1,4 +1,4 @@ -import type { OpenCodeEvent, SessionInboxItem, SessionMessageInfo } from "@opencode-ai/client/promise" +import type { OpenCodeEvent, SessionInboxItem, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise" type Assistant = Extract type Compaction = Extract @@ -8,13 +8,18 @@ export type V2SessionReduction = { sessionID: string messages: SessionMessageInfo[] touched: string[] + removed?: string[] missing?: string } export function createV2SessionReducer() { const pending = new Map() - const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => { + const reduce = ( + source: readonly SessionMessageInfo[], + event: OpenCodeEvent, + session?: Pick, + ): V2SessionReduction | undefined => { if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return const sessionID = event.data.sessionID const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({ @@ -28,13 +33,39 @@ export function createV2SessionReducer() { switch (event.type) { case "session.inbox.enqueued": pending.set(key(sessionID, event.data.inboxID), event.data.item) - return result([...source]) + if (event.data.item.type === "user") + return append({ + id: event.data.inboxID, + type: "user", + metadata: event.data.item.payload.metadata, + text: event.data.item.payload.text, + files: event.data.item.payload.files, + agents: event.data.item.payload.agents, + time: { created: event.created }, + }) + if (event.data.item.type !== "synthetic") return result([...source]) + return append({ + id: event.data.inboxID, + type: "synthetic", + metadata: event.data.item.payload.metadata, + text: event.data.item.payload.text, + description: event.data.item.payload.description, + time: { created: event.created }, + }) case "session.inbox.cancelled": pending.delete(key(sessionID, event.data.inboxID)) - return + return { + ...result(source.filter((item) => item.id !== event.data.inboxID)), + removed: source.some((item) => item.id === event.data.inboxID) ? [event.data.inboxID] : [], + } case "session.inbox.delivered": { const input = pending.get(key(sessionID, event.data.inboxID)) pending.delete(key(sessionID, event.data.inboxID)) + const existing = source.find((item) => item.id === event.data.inboxID) + if (existing) { + const promoted = { ...existing, time: { ...existing.time, created: event.created } } + return result([...source.filter((item) => item.id !== existing.id), promoted], [existing.id]) + } if (!input) return { ...result([...source]), missing: event.data.inboxID } if (input.type === "user") return append({ @@ -84,6 +115,32 @@ export function createV2SessionReducer() { )?.model, time: { created: event.created }, }) + case "session.moved": + if (!session) return + return append({ + id: messageID(event.id), + type: "location-switched", + metadata: event.metadata, + location: event.data.location, + projectID: event.data.projectID, + subpath: event.data.subpath, + previous: { + location: session.location, + projectID: session.projectID, + subpath: session.subpath, + }, + time: { created: event.created }, + }) + case "session.instructions.updated": + if (event.data.text === undefined) return + return append({ + id: messageID(event.id), + type: "system", + text: event.data.text, + description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`, + metadata: event.metadata, + time: { created: event.created }, + }) case "session.synthetic": return append({ id: messageID(event.id), @@ -403,8 +460,11 @@ export function createV2SessionReducer() { type: "compaction", status: "failed", metadata: current?.metadata ?? event.metadata, - reason: event.data.reason, - error: event.data.error, + reason: event.data.reason ?? "manual", + error: event.data.error ?? { + type: "compaction.failed", + message: "Compaction failed before recording an error", + }, time: current?.time ?? { created: event.created }, } if (!current) return append(failed) diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 662e94813f3..c3121caf974 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test" import type { retry } from "@opencode-ai/core/util/retry" import type { + FormInfo, OpenCodeEvent, SessionApi, + SessionInboxInfo, SessionInfo, SessionMessageAssistant, SessionMessageAssistantTool, @@ -348,6 +350,187 @@ describe("server session", () => { expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }]) }) + test("projects V2 pending inputs and forms", () => { + const ctx = setup({ child: session("child") }) + const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent) + + apply({ + id: "evt_admitted", + created: 1, + type: "session.inbox.enqueued", + data: { + sessionID: "child", + inboxID: "msg_input", + item: { type: "user", delivery: "steer", payload: { text: "hello" } }, + }, + }) + apply({ + id: "evt_form", + created: 2, + type: "form.created", + data: { form: { id: "frm_1", sessionID: "child", title: "Choose", fields: [] } }, + }) + + expect(ctx.store.data.pending.child).toMatchObject([{ id: "msg_input", delivery: "steer" }]) + expect(ctx.store.data.input.child).toEqual(["msg_input"]) + expect(ctx.store.data.form.child).toMatchObject([{ id: "frm_1", title: "Choose" }]) + expect(ctx.store.data.message.child).toMatchObject([{ id: "msg_input" }]) + + apply({ + id: "evt_cancelled", + created: 3, + type: "session.inbox.cancelled", + data: { sessionID: "child", inboxID: "msg_input" }, + }) + apply({ + id: "evt_form_done", + created: 4, + type: "form.cancelled", + data: { sessionID: "child", id: "frm_1" }, + }) + + expect(ctx.store.data.pending.child).toEqual([]) + expect(ctx.store.data.input.child).toEqual([]) + expect(ctx.store.data.form.child).toEqual([]) + expect(ctx.store.data.message.child).toEqual([]) + }) + + test("does not let transient hydration overwrite newer events", async () => { + const ctx = setup({ child: session("child") }) + const response = Promise.withResolvers<{ pending: SessionInboxInfo[]; forms: FormInfo[] }>() + const form: FormInfo = { + id: "frm_1", + sessionID: "child", + title: "Choose", + fields: [{ key: "choice", type: "string" as const, title: "Choice" }], + } + let loads = 0 + const hydration = ctx.store.hydrateTransient("child", () => { + loads += 1 + if (loads === 1) return response.promise + return Promise.resolve({ pending: ctx.store.data.pending.child ?? [], forms: [form] }) + }) + + ctx.store.applyV2({ + id: "evt_admitted", + created: 1, + type: "session.inbox.enqueued", + data: { + sessionID: "child", + inboxID: "msg_input", + item: { type: "user", delivery: "queue", payload: { text: "new" } }, + }, + } as OpenCodeEvent) + response.resolve({ + pending: [], + forms: [form], + }) + await hydration + + expect(ctx.store.data.pending.child).toMatchObject([{ id: "msg_input" }]) + expect(ctx.store.data.form.child).toMatchObject([{ id: "frm_1" }]) + expect(loads).toBe(2) + }) + + test("removes only the compaction input named by the start event", () => { + const ctx = setup({ child: session("child") }) + const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent) + apply({ + id: "evt_compaction_inbox", + created: 1, + type: "session.inbox.enqueued", + data: { + sessionID: "child", + inboxID: "msg_compaction", + item: { type: "compaction", delivery: "queue", payload: { reason: "manual" } }, + }, + }) + + expect(ctx.store.data.input.child).toBeUndefined() + expect(ctx.store.data.pending.child).toHaveLength(1) + + apply({ + id: "evt_compaction_inbox_2", + created: 2, + type: "session.inbox.enqueued", + data: { + sessionID: "child", + inboxID: "msg_compaction_2", + item: { type: "compaction", delivery: "queue", payload: { reason: "manual" } }, + }, + }) + apply({ + id: "evt_compaction_ended", + created: 3, + type: "session.compaction.ended", + data: { sessionID: "child", reason: "manual", text: "summary", recent: "recent" }, + }) + + expect(ctx.store.data.pending.child).toHaveLength(2) + + apply({ + id: "evt_compaction_started", + created: 4, + type: "session.compaction.started", + data: { sessionID: "child", inputID: "msg_compaction", reason: "manual" }, + }) + + expect(ctx.store.data.pending.child?.map((item) => item.id)).toEqual(["msg_compaction_2"]) + }) + + test("projects committed revert before server reconciliation", () => { + const ctx = setup({ child: session("child") }) + ctx.store.remember({ ...session("child"), revert: { messageID: "msg_2", partID: "prt_1" } }) + ctx.store.set("input", "child", ["msg_1", "msg_2"]) + ctx.store.set("session_message", "child", [ + { id: "msg_1", type: "user", text: "keep", time: { created: 1 } }, + { id: "msg_2", type: "user", text: "remove", time: { created: 2 } }, + ]) + + ctx.store.applyV2({ + id: "evt_revert", + created: 3, + type: "session.revert.committed", + data: { sessionID: "child", to: "msg_2" }, + } as OpenCodeEvent) + + expect(ctx.store.data.info.child?.revert).toBeUndefined() + expect(ctx.store.data.input.child).toEqual(["msg_1"]) + expect(ctx.store.data.session_message.child?.map((message) => message.id)).toEqual(["msg_1"]) + }) + + test("does not restore a message hydrated before a committed revert", async () => { + const response = Promise.withResolvers() + const store = createServerSession({ + session: { + get: async () => session("child"), + message: () => response.promise, + } as unknown as SessionApi, + message: { + list: async () => currentPage({ data: [], response: { headers: new Headers() } }), + } as unknown as MessageApi, + }) + store.remember(session("child")) + + store.applyV2({ + id: "evt_delivered", + created: 1, + type: "session.inbox.delivered", + data: { sessionID: "child", inboxID: "msg_2" }, + } as OpenCodeEvent) + store.applyV2({ + id: "evt_revert", + created: 2, + type: "session.revert.committed", + data: { sessionID: "child", to: "msg_2" }, + } as OpenCodeEvent) + response.resolve({ id: "msg_2", type: "user", text: "stale", time: { created: 1 } }) + await response.promise + await Bun.sleep(0) + + expect(store.data.session_message.child ?? []).toEqual([]) + }) + test("resolves lineage by session ID without directory", async () => { const ctx = setup({ child: session("child", "root"), root: session("root") }) @@ -368,6 +551,17 @@ describe("server session", () => { expect(ctx.store.data.message.root).toEqual([]) }) + test("reloads cached sessions after reconnect invalidation", async () => { + const ctx = setup({ root: session("root") }) + await ctx.store.sync("root") + + ctx.store.invalidate() + await ctx.store.sync("root") + + expect(ctx.get).toHaveLength(2) + expect(ctx.messages).toHaveLength(2) + }) + test("loads current session content through the current message API", async () => { const requests: unknown[] = [] const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } } diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 5f23ca39e6c..1139abcdadf 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -1,7 +1,14 @@ import { Binary } from "@opencode-ai/core/util/binary" import { ProjectDirectories } from "@opencode-ai/schema/project-directories" import { retry } from "@opencode-ai/core/util/retry" -import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise" +import type { + FormInfo, + OpenCodeEvent, + SessionApi, + SessionInfo, + SessionInboxInfo, + SessionMessageInfo, +} from "@opencode-ai/client/promise" import type { Message, Part, Todo } from "@/types" import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" import { batch } from "solid-js" @@ -192,6 +199,9 @@ export function createServerSession( todo: {} as Record, permission: {} as Record, question: {} as Record, + form: {} as Record, + pending: {} as Record, + input: {} as Record, message: {} as Record, session_message: {} as Record, part: {} as Record, @@ -205,6 +215,11 @@ export function createServerSession( const inflightTodo = new Map>() const optimistic = new Map>() const v2 = createV2SessionReducer() + const pendingRevision = new Map() + const formRevision = new Map() + const messageHydrationRevision = new Map() + const invalidated = new Set() + let invalidationRevision = 0 const messageLoads = new Map() const pendingParts = new Map>>() const orphanParts = new Map>() @@ -263,6 +278,9 @@ export function createServerSession( ...Object.entries(data.question) .filter(([, items]) => items.length > 0) .map(([sessionID]) => sessionID), + ...Object.entries(data.form) + .filter(([, items]) => items.length > 0) + .map(([sessionID]) => sessionID), ...Object.entries(data.session_status) .filter(([, status]) => status.type !== "idle") .map(([sessionID]) => sessionID), @@ -464,6 +482,7 @@ export function createServerSession( if (evicted.has(item.sessionID)) deltaBases.delete(partID) } sessionIDs.forEach((sessionID) => { + messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1) generations.delete(sessionID) clearOptimistic(sessionID) requests.delete(sessionID) @@ -507,6 +526,9 @@ export function createServerSession( ...Object.entries(data.question) .filter(([, items]) => items.length > 0) .map(([sessionID]) => sessionID), + ...Object.entries(data.form) + .filter(([, items]) => items.length > 0) + .map(([sessionID]) => sessionID), ...Object.entries(data.session_status) .filter(([, status]) => status.type !== "idle") .map(([sessionID]) => sessionID), @@ -798,13 +820,16 @@ export function createServerSession( touch(sessionID) return runInflight(inflight, sessionID, async () => { const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined - if (cached && data.info[sessionID] && !options?.force) return + const invalid = invalidated.has(sessionID) + const revision = invalidationRevision + if (cached && data.info[sessionID] && !invalid && !options?.force) return await Promise.all([ - resolve(sessionID, options), - cached && !options?.force + resolve(sessionID, invalid ? { ...options, force: true } : options), + cached && !invalid && !options?.force ? Promise.resolve() : loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize), ]) + if (invalid && invalidationRevision === revision) invalidated.delete(sessionID) }) } @@ -844,7 +869,7 @@ export function createServerSession( const projectV2 = (reduction: V2SessionReduction) => { reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID)) setData("session_message", reduction.sessionID, reconcile(reduction.messages)) - if (reduction.touched.length === 0) return + if (reduction.touched.length === 0 && !reduction.removed?.length) return const touched = new Set(reduction.touched) let parentID: string | undefined @@ -861,6 +886,9 @@ export function createServerSession( const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages) batch(() => { + for (const messageID of reduction.removed ?? []) { + apply({ type: "message.removed", properties: { sessionID: reduction.sessionID, messageID } }) + } for (const message of normalized.messages) { if (!touched.has(message.id)) continue apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } }) @@ -884,9 +912,14 @@ export function createServerSession( const hydrateV2Message = (sessionID: string, messageID: string) => { if (!sessionApi) return + const active = generation(sessionID) + const revision = messageHydrationRevision.get(sessionID) ?? 0 void sessionApi .message({ sessionID, messageID }) .then((message) => { + if (generations.get(sessionID) !== active) return + if ((messageHydrationRevision.get(sessionID) ?? 0) !== revision) return + if (removedMessages.get(sessionID)?.has(message.id)) return const current = data.session_message[sessionID] ?? [] const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages) projectV2({ sessionID, messages, touched: [message.id] }) @@ -895,6 +928,18 @@ export function createServerSession( } const applyV2 = (event: OpenCodeEvent) => { + if (event.type === "form.created") { + formRevision.set(event.data.form.sessionID, (formRevision.get(event.data.form.sessionID) ?? 0) + 1) + const current = data.form[event.data.form.sessionID] ?? [] + if (!current.some((form) => form.id === event.data.form.id)) + setData("form", event.data.form.sessionID, [...current, event.data.form]) + return + } + if (event.type === "form.replied" || event.type === "form.cancelled") { + formRevision.set(event.data.sessionID, (formRevision.get(event.data.sessionID) ?? 0) + 1) + setData("form", event.data.sessionID, (forms) => forms?.filter((form) => form.id !== event.data.id)) + return + } if (event.type === "project.directory.resolved") { Object.values(data.info).forEach((info) => { if (!info) return @@ -908,19 +953,63 @@ export function createServerSession( } if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return const sessionID = event.data.sessionID - const reduction = v2.reduce(data.session_message[sessionID] ?? [], event) + if ( + event.type === "session.inbox.enqueued" || + event.type === "session.inbox.delivery.changed" || + event.type === "session.inbox.cancelled" || + event.type === "session.inbox.delivered" || + event.type === "session.compaction.started" || + event.type === "session.compaction.failed" + ) + pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1) + if (event.type === "session.inbox.enqueued") { + const current = data.pending[sessionID] ?? [] + if (!current.some((item) => item.id === event.data.inboxID)) + setData("pending", sessionID, [ + ...current, + { id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item }, + ]) + if (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID)) + setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID]) + } + if (event.type === "session.inbox.delivery.changed") + setData("pending", sessionID, (items) => + items?.map((item) => + item.id === event.data.inboxID ? { ...item, delivery: event.data.delivery } : item, + ), + ) + if (event.type === "session.inbox.cancelled" || event.type === "session.inbox.delivered") { + setData("pending", sessionID, (items) => items?.filter((item) => item.id !== event.data.inboxID)) + setData("input", sessionID, (items) => items?.filter((id) => id !== event.data.inboxID)) + } + if (event.type === "session.compaction.started" || event.type === "session.compaction.failed") { + setData("pending", sessionID, (items) => items?.filter((item) => item.id !== event.data.inputID)) + setData("input", sessionID, (items) => items?.filter((id) => id !== event.data.inputID)) + } + const info = data.info[sessionID] + const reduction = v2.reduce(data.session_message[sessionID] ?? [], event, info) if (reduction) { projectV2(reduction) if (reduction.missing) hydrateV2Message(sessionID, reduction.missing) } - const info = data.info[sessionID] + if (event.type === "session.agent.selected" && info) remember({ ...info, agent: event.data.agent }) + if (event.type === "session.model.selected") { + if (info) remember({ ...info, model: event.data.model }) + if (data.session_message[sessionID]) hydrateV2Message(sessionID, event.id.replace(/^evt_/, "msg_")) + } if (event.type === "session.renamed" && info) remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } }) + if (event.type === "session.renamed" && !info) + void resolve(sessionID) + .then((current) => + remember({ ...current, title: event.data.title, time: { ...current.time, updated: event.created } }), + ) + .catch(() => undefined) if (event.type === "session.moved" && info) remember({ ...info, - projectID: event.data.projectID ?? info.projectID, + projectID: event.data.projectID, location: event.data.location, subpath: event.data.subpath, time: { ...info.time, updated: event.created }, @@ -946,12 +1035,29 @@ export function createServerSession( next: event.data.at, }) if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {}) + if (event.type === "session.revert.staged" && info) remember({ ...info, revert: event.data.revert }) + if (event.type === "session.revert.cleared" && info) remember({ ...info, revert: undefined }) + if (event.type === "session.revert.committed") { + messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1) + if (info) remember({ ...info, revert: undefined }) + setData("input", sessionID, (items) => items?.filter((id) => id < event.data.to)) + const source = data.session_message[sessionID] ?? [] + const removed = source.filter((message) => message.id >= event.data.to).map((message) => message.id) + removedMessages.set(sessionID, new Set([...(removedMessages.get(sessionID) ?? []), ...removed])) + projectV2({ + sessionID, + messages: source.filter((message) => message.id < event.data.to), + touched: [], + removed, + }) + } if ( event.type === "session.revert.staged" || event.type === "session.revert.cleared" || event.type === "session.revert.committed" ) void resolve(sessionID, { force: true }).catch(() => {}) + if (event.type === "session.revert.committed") void sync(sessionID, { force: true }).catch(() => {}) } const apply = (event: { type: string; properties?: unknown }) => { @@ -1280,6 +1386,39 @@ export function createServerSession( }, }, sync, + async hydrateTransient( + sessionID: string, + load: () => Promise<{ pending: SessionInboxInfo[]; forms: FormInfo[] }>, + ) { + while (true) { + const pendingAt = pendingRevision.get(sessionID) ?? 0 + const formAt = formRevision.get(sessionID) ?? 0 + const result = await load() + const pendingStable = (pendingRevision.get(sessionID) ?? 0) === pendingAt + const formStable = (formRevision.get(sessionID) ?? 0) === formAt + if (pendingStable) { + setData("pending", sessionID, reconcile(result.pending)) + setData( + "input", + sessionID, + reconcile(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id)), + ) + } + if (formStable) setData("form", sessionID, reconcile(result.forms)) + if (pendingStable && formStable) return + } + }, + refreshPinned(hydrateTransient: (sessionID: string) => Promise) { + return Promise.all( + [...pinned.keys()].flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]), + ).then(() => undefined) + }, + invalidate() { + invalidationRevision += 1 + Object.keys(data.info).forEach((sessionID) => invalidated.add(sessionID)) + Object.keys(data.message).forEach((sessionID) => invalidated.add(sessionID)) + setMeta("at", {}) + }, prefetch, shouldPrefetch(sessionID: string, limit: number) { if (data.message[sessionID] === undefined) return true diff --git a/packages/app/src/context/server-sync.test.ts b/packages/app/src/context/server-sync.test.ts index ff27cdac683..91a62925fa4 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/packages/app/src/context/server-sync.test.ts @@ -9,7 +9,13 @@ import type { import { QueryClient } from "@tanstack/solid-query" import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load" -import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync" +import { + loadActiveSessionsQuery, + loadMcpQuery, + loadMcpResourcesQuery, + reconcileActiveSessionStatuses, + seedActiveSessionStatuses, +} from "./server-sync" import { ServerScope } from "@/utils/server-scope" import { createServerSession } from "./server-session" import type { ServerApi } from "@/utils/server" @@ -99,6 +105,17 @@ describe("active session query", () => { next: 10, }) }) + + test("replaces stale active statuses after reconnect", () => { + const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"]) + session.set("session_status", "ses_stale", { type: "busy" }) + session.set("session_status", "ses_active", { type: "idle" }) + + reconcileActiveSessionStatuses(session, { ses_active: { type: "running" } }) + + expect(session.data.session_status.ses_stale).toEqual({ type: "idle" }) + expect(session.data.session_status.ses_active).toEqual({ type: "busy" }) + }) }) describe("pickDirectoriesToEvict", () => { diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 8fe796067ff..015187c20bc 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,11 +1,11 @@ import type { Config, Path, Project, ProviderAuthResponse } from "@/types" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" -import { batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" +import { batch, createMemo, getOwner, onCleanup, untrack } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import type { InitError } from "../pages/error" -import { ServerSDK } from "./server-sdk" +import { type ServerEvent, type ServerSDK } from "./server-sdk" import { bootstrapDirectory, bootstrapGlobal, @@ -13,6 +13,7 @@ import { loadAgentsQuery, loadCommands, loadGlobalConfigQuery, + loadIntegrationsQuery, loadPathQuery, loadProjectsQuery, loadProvidersQuery, @@ -29,7 +30,7 @@ import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from import type { SolidQueryOptions } from "@tanstack/solid-query" import { createRefreshQueue } from "./global-sync/queue" import { directoryKey } from "./global-sync/utils" -import { PathKey } from "@/utils/path-key" +import { pathKey, PathKey } from "@/utils/path-key" import { createDirSyncContext } from "./directory-sync" import { createSimpleContext } from "@opencode-ai/ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" @@ -53,6 +54,8 @@ import type { } from "@opencode-ai/client/promise" import { toggleMcp } from "./global-sync/mcp" import { createServerSession, type ServerSession } from "./server-session" +import { createCatalogSync } from "./server-sync/catalog" +import { createConnectionSync } from "./server-sync/connection" import { usePlatform } from "./platform" type GlobalStore = { @@ -66,6 +69,16 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } +const SESSION_LIST_EVENTS = new Set([ + "session.created", + "session.updated", + "session.deleted", + "session.moved", + "session.forked", + "session.renamed", + "session.usage.updated", +]) + type McpListApi = { readonly list: (input?: McpListInput) => Promise } @@ -159,11 +172,22 @@ export function seedActiveSessionStatuses( } } +export function reconcileActiveSessionStatuses( + session: Pick, + active: SessionActiveOutput, +) { + Object.keys(session.data.session_status) + .filter((sessionID) => active[sessionID] === undefined && session.data.session_status[sessionID]?.type !== "idle") + .forEach((sessionID) => session.set("session_status", sessionID, { type: "idle" })) + Object.keys(active).forEach((sessionID) => session.set("session_status", sessionID, { type: "busy" })) +} + function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) { return { globalConfig: () => loadGlobalConfigQuery(scope), projects: () => loadProjectsQuery(scope, serverAPI.project), providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI), + integrations: (directory: PathKey | null) => loadIntegrationsQuery(scope, directory, serverAPI.integration), path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location), agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent), references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference), @@ -184,29 +208,46 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const booting = new Map>() const sessionLoads = new Map>() const sessionMeta = new Map() + const sessionRevision = new Map() const session = createServerSession(serverSDK.api.session, serverSDK.api.message) const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api) + const connected = () => serverSDK.connection.status() === "connected" + const hydrateSessionState = async (sessionID: string) => { + await session.hydrateTransient(sessionID, async () => { + const [pending, forms] = await Promise.all([ + serverSDK.api.session.inbox.list({ sessionID }), + serverSDK.api.form.list({ sessionID }), + ]) + return { pending, forms } + }) + } + const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)]) const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ - queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], + queries: [ + { ...queryOptionsApi.globalConfig(), enabled: connected() }, + { ...queryOptionsApi.providers(null), enabled: connected() }, + { ...queryOptionsApi.path(null), enabled: connected() }, + ], })) - const activeSessionsQuery = useQuery(() => - loadActiveSessionsQuery(serverSDK.scope, { + const activeSessionsQuery = useQuery(() => ({ + ...loadActiveSessionsQuery(serverSDK.scope, { active: async () => { const active = await serverSDK.api.session.active() - seedActiveSessionStatuses(session, active) - for (const sessionID of Object.keys(active)) { - void session.resolve(sessionID).catch(() => undefined) - } + reconcileActiveSessionStatuses(session, active) + Object.keys(active).forEach((sessionID) => { + void Promise.all([session.resolve(sessionID), hydrateSessionState(sessionID)]).catch(() => undefined) + }) return active }, }), - ) + enabled: connected(), + })) const [globalStore, setGlobalStore] = createStore({ get ready() { - return !bootstrap.isPending + return bootstrap.isSuccess }, project: [], provider_auth: {}, @@ -231,21 +272,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const queryClient = useQueryClient() const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server)) - const refreshProviders = () => - queryClient.refetchQueries({ - predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers", - }) - - let bootedAt = 0 - let bootingRoot = false - let eventFrame: number | undefined - let eventTimer: ReturnType | undefined - - onCleanup(() => { - if (eventFrame !== undefined) cancelAnimationFrame(eventFrame) - if (eventTimer !== undefined) clearTimeout(eventTimer) - }) - const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { setGlobalStore("project", next) } @@ -270,9 +296,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { setGlobalStore: setBootStore, queryClient, }) - bootedAt = Date.now() - return bootedAt + return Date.now() }, + enabled: connected(), })) const set = ((...input: unknown[]) => { @@ -294,11 +320,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const children = createChildStoreManager({ owner, + connected, scope: serverSDK.scope, persist: persisted, isBooting: (directory) => booting.has(directory), isLoadingSessions: (directory) => sessionLoads.has(directory), onBootstrap: (directory) => { + if (!connected()) return void bootstrapInstance(directory) }, onMcp: (directory, setStore) => { @@ -321,9 +349,60 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { translate: language.t, queryOptions: queryOptionsApi, global: { - provider: globalStore.provider, + get provider() { + return globalStore.provider + }, }, }) + const catalog = createCatalogSync({ + scope: serverSDK.scope, + queryClient, + active: () => Object.keys(children.children).filter(children.active).map(pathKey), + load: (directory) => + Promise.all([ + queryClient.fetchQuery(queryOptionsApi.providers(directory)), + queryClient.fetchQuery(queryOptionsApi.integrations(directory)), + ]).then(() => undefined), + }) + const refreshVcs = (directory: string) => + serverSDK.api.vcs + .get({ location: { directory } }) + .then((result) => + children.vcs(directory, { + branch: result.data.branch.current, + default_branch: result.data.branch.default, + }), + ) + .catch(() => undefined) + const connection = createConnectionSync({ + status: serverSDK.connection.status, + invalidate: () => { + session.invalidate() + void queryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0] === serverSDK.scope, + refetchType: "none", + }) + }, + connected: (info) => { + if (info.reconnect) void session.refreshPinned(hydrateSessionState).catch(() => undefined) + if (activeSessionsQuery.data !== undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch() + if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch() + Object.keys(children.children) + .filter(children.active) + .forEach((directory) => { + queue.push(directory) + if (children.children[directory]?.[0].status !== "loading") void refreshVcs(directory) + }) + }, + }) + + async function loadCurrentSessions(directory: string, key: PathKey, limit: number) { + while (true) { + const revision = sessionRevision.get(key) ?? 0 + const result = await loadRootSessions({ api: serverSDK.api.session, directory, limit }) + if ((sessionRevision.get(key) ?? 0) === revision) return result + } + } async function loadSessions(directory: string, options?: { limit?: number }) { const key = directoryKey(directory) @@ -354,7 +433,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { .fetchQuery({ ...queryOptionsApi.sessions(key), queryFn: () => - loadRootSessions({ api: serverSDK.api.session, directory, limit }) + loadCurrentSessions(directory, key, limit) .then((x) => { const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) @@ -410,27 +489,28 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { children.pin(key) const promise = Promise.resolve().then(async () => { const child = children.ensureChild(directory) - const cache = children.vcsCache.get(key) - if (!cache) return - await bootstrapDirectory({ - directory, - scope: serverSDK.scope, - mcp: children.mcp(key), - global: { - config: globalStore.config, - path: globalStore.path, - project: globalStore.project, - provider: globalStore.provider, - }, - api: serverSDK.api, - store: child[0], - setStore: child[1], - vcsCache: cache, - loadSessions, - translate: language.t, - queryClient, - session, - }) + const initial = child[0].status === "loading" + await Promise.all([ + bootstrapDirectory({ + directory, + scope: serverSDK.scope, + mcp: children.mcp(key), + global: { + config: globalStore.config, + path: globalStore.path, + project: globalStore.project, + provider: globalStore.provider, + }, + api: serverSDK.api, + store: child[0], + setStore: child[1], + loadSessions, + translate: language.t, + queryClient, + session, + }), + initial ? refreshVcs(directory) : Promise.resolve(), + ]) }) booting.set(key, promise) @@ -457,14 +537,43 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { loadLsp() {}, }) } + const updateHomeSession = (info: Parameters[0]) => + homeSessions.apply({ + type: "session.updated", + properties: { sessionID: info.id, info }, + }) + const markSessionListChanged = (event: ServerEvent, directory: string, previousDirectory?: string) => { + if (SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) { + const key = directoryKey(directory) + sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1) + } + if (!previousDirectory || previousDirectory === directory) return + const key = directoryKey(previousDirectory) + sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1) + } + const toDirectoryEvent = (event: ServerEvent) => { + if (event.current?.type === "session.created") return + if ( + event.current?.type !== "session.renamed" && + event.current?.type !== "session.moved" && + event.current?.type !== "session.usage.updated" + ) + return event + const info = session.get(event.current.data.sessionID) + if (info) return { type: "session.updated", properties: { info } } + return event + } const unsub = serverSDK.event.listen((e) => { const directory = e.name const key = directoryKey(directory) const event = e.details const eventType: string = event.type - const recent = bootingRoot || Date.now() - bootedAt < 1500 - + const previousDirectory = + event.current?.type === "session.moved" + ? session.get(event.current.data.sessionID)?.location.directory + : undefined + markSessionListChanged(event, directory, previousDirectory) if (event.current) session.applyV2(event.current) session.apply(event) if (event.current?.type === "session.created") @@ -487,35 +596,42 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { if (event.type === "session.created" || event.type === "session.deleted") { if ("info" in event.properties) homeSessions.apply(event as Parameters[0]) } + if ( + event.current?.type === "session.renamed" || + event.current?.type === "session.moved" || + event.current?.type === "session.usage.updated" + ) { + const sessionID = event.current.data.sessionID + const info = session.get(sessionID) + if (info) updateHomeSession(info) + if (!info) + void session + .resolve(sessionID) + .then(() => { + const current = session.get(sessionID) + if (current) updateHomeSession(current) + }) + .catch(() => undefined) + } homeSessions.refresh(event.type) - if (eventType === "integration.connection.updated") void refreshProviders() + catalog.handleEvent({ type: eventType, directory }) + connection.handleEvent({ type: eventType, directory }) if (directory === "global") { - if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching) - void activeSessionsQuery.refetch() applyGlobalEvent({ event, project: globalStore.project, - refresh: () => { - if (recent) return - bootstrap.refetch() - }, + refresh: () => void bootstrap.refetch(), setGlobalProject: setProjects, }) if ( eventType === "config.updated" || - eventType === "catalog.updated" || eventType === "agent.updated" || eventType === "project.directories.updated" ) bootstrap.refetch() - if (eventType === "server.connected" || eventType === "global.disposed") { - if (recent) return - for (const directory of Object.keys(children.children)) { - if (!children.active(directory)) continue - queue.push(directory) - } - } + if (eventType === "global.disposed") + Object.keys(children.children).filter(children.active).forEach(queue.push) return } @@ -536,7 +652,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { event.current?.type === "session.moved" || // event.current?.type === "session.archived" || event.current?.type === "session.forked" || - eventType === "command.updated" || eventType === "config.updated" || eventType === "agent.updated" ) @@ -544,27 +659,39 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key)) if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key)) const [store, setStore] = existing - applyDirectoryEvent({ - event, - directory, - store, - setStore, - push: (directory) => { - if (children.active(directory)) queue.push(directory) - }, - retainedLimit: sessionMeta.get(key)?.limit, - sessionContent: false, - permission: session.data.permission, - vcsCache: children.vcsCache.get(key), - loadLsp: () => { - if (!children.active(key)) return - void queryClient.fetchQuery(queryOptionsApi.lsp(key)) - }, - loadReferences: () => { - if (!children.active(key)) return - void queryClient.fetchQuery(queryOptionsApi.references(key)) - }, - }) + if (eventType === "agent.updated") + void queryClient + .fetchQuery(queryOptionsApi.agents(key)) + .then((data) => setStore("agent", data)) + .catch(() => {}) + if (eventType === "command.updated") + void loadCommands(directory, serverSDK.api.command) + .then((commands) => setStore("command", commands)) + .catch(() => {}) + if (eventType === "project.directories.updated") void bootstrap.refetch() + const projected = toDirectoryEvent(event) + if (projected) + applyDirectoryEvent({ + event: projected, + directory, + store, + setStore, + push: (directory) => { + if (children.active(directory)) queue.push(directory) + }, + retainedLimit: sessionMeta.get(key)?.limit, + sessionContent: false, + permission: session.data.permission, + vcsCache: children.vcsCache.get(key), + loadLsp: () => { + if (!children.active(key)) return + void queryClient.fetchQuery(queryOptionsApi.lsp(key)) + }, + loadReferences: () => { + if (!children.active(key)) return + void queryClient.fetchQuery(queryOptionsApi.references(key)) + }, + }) }) onCleanup(unsub) @@ -577,23 +704,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { } }) - onMount(() => { - if (typeof requestAnimationFrame === "function") { - eventFrame = requestAnimationFrame(() => { - eventFrame = undefined - eventTimer = setTimeout(() => { - eventTimer = undefined - void serverSDK.event.start() - }, 0) - }) - } else { - eventTimer = setTimeout(() => { - eventTimer = undefined - void serverSDK.event.start() - }, 0) - } - }) - const projectApi = { loadSessions, meta(directory: string, patch: ProjectMeta) { @@ -634,11 +744,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { peek: children.peek, disableMcp: children.disableMcp, queryOptions: queryOptionsApi, - refreshProviders, + refreshProviders: catalog.refreshActive, // bootstrap, updateConfig: updateConfigMutation.mutateAsync, project: projectApi, - session, + session: Object.assign(session, { hydrate: hydrateSession }), homeSessions, mcp: { toggle: async (directory: string, name: string) => { diff --git a/packages/app/src/context/server-sync/catalog.test.ts b/packages/app/src/context/server-sync/catalog.test.ts new file mode 100644 index 00000000000..c653b79ceb8 --- /dev/null +++ b/packages/app/src/context/server-sync/catalog.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test" +import { QueryClient } from "@tanstack/solid-query" +import { ServerScope } from "@/utils/server-scope" +import { createCatalogSync } from "./catalog" +import { pathKey } from "@/utils/path-key" + +test("invalidates the catalog for the event location", async () => { + const queryClient = new QueryClient() + const one = [ServerScope.local, "/one", "providers"] as const + const integrations = [ServerScope.local, "/one", "integrations"] as const + const two = [ServerScope.local, "/two", "providers"] as const + queryClient.setQueryData(one, { providers: ["one"] }) + queryClient.setQueryData(integrations, { integrations: ["one"] }) + queryClient.setQueryData(two, { providers: ["two"] }) + const catalog = createCatalogSync({ + scope: ServerScope.local, + queryClient, + active: () => [pathKey("/one"), pathKey("/two")], + load: async () => {}, + }) + + catalog.handleEvent({ type: "catalog.updated", directory: "/one" }) + await Bun.sleep(0) + + expect(queryClient.getQueryState(one)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(integrations)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(two)?.isInvalidated).toBe(false) +}) + +test("invalidates global and active catalogs after connection", async () => { + const queryClient = new QueryClient() + const global = [ServerScope.local, null, "providers"] as const + const active = [ServerScope.local, "/active", "providers"] as const + const passive = [ServerScope.local, "/passive", "providers"] as const + queryClient.setQueryData(global, {}) + queryClient.setQueryData(active, {}) + queryClient.setQueryData(passive, {}) + const catalog = createCatalogSync({ + scope: ServerScope.local, + queryClient, + active: () => [pathKey("/active")], + load: async () => {}, + }) + + catalog.handleEvent({ type: "server.connected", directory: "global" }) + await Bun.sleep(0) + + expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(active)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(passive)?.isInvalidated).toBe(false) +}) diff --git a/packages/app/src/context/server-sync/catalog.ts b/packages/app/src/context/server-sync/catalog.ts new file mode 100644 index 00000000000..02c6153e3d1 --- /dev/null +++ b/packages/app/src/context/server-sync/catalog.ts @@ -0,0 +1,53 @@ +import type { QueryClient } from "@tanstack/solid-query" +import type { ServerScope } from "@/utils/server-scope" +import { pathKey, type PathKey } from "@/utils/path-key" + +type CatalogEvent = { + type: string + directory: string +} + +export function createCatalogSync(input: { + scope: ServerScope + queryClient: QueryClient + active: () => PathKey[] + load: (directory: PathKey | null) => Promise +}) { + function handleEvent(event: CatalogEvent) { + if (event.type === "server.connected") { + void refreshActive().catch(() => undefined) + return + } + + if ( + event.type === "catalog.updated" || + event.type === "integration.updated" || + event.type === "integration.connection.updated" + ) { + void refresh(event.directory === "global" ? null : pathKey(event.directory)).catch(() => undefined) + } + } + + async function refresh(directory: PathKey | null) { + await Promise.all( + ["providers", "integrations"].map((resource) => + input.queryClient.invalidateQueries({ + queryKey: [input.scope, directory, resource], + exact: true, + refetchType: "none", + }), + ), + ) + await input.load(directory) + } + + function refreshActive() { + return Promise.all([null, ...new Set(input.active())].map(refresh)).then(() => undefined) + } + + return { + handleEvent, + refresh, + refreshActive, + } +} diff --git a/packages/app/src/context/server-sync/connection.test.ts b/packages/app/src/context/server-sync/connection.test.ts new file mode 100644 index 00000000000..cfa50e9291a --- /dev/null +++ b/packages/app/src/context/server-sync/connection.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { createConnectionSync } from "./connection" + +test("invalidates disconnected data and synchronizes after the handshake", () => { + const calls: string[] = [] + const dispose = createRoot((dispose) => { + const [status, setStatus] = createSignal<"connecting" | "connected" | "reconnecting">("connecting") + const connection = createConnectionSync({ + status, + invalidate: () => calls.push("invalidate"), + connected: () => calls.push("connected"), + }) + + connection.handleEvent({ type: "server.connected", directory: "global" }) + expect(calls).toContain("connected") + connection.handleEvent({ type: "server.connected", directory: "/repo" }) + expect(calls.filter((call) => call === "connected")).toHaveLength(1) + setStatus("connected") + return dispose + }) + dispose() +}) diff --git a/packages/app/src/context/server-sync/connection.ts b/packages/app/src/context/server-sync/connection.ts new file mode 100644 index 00000000000..b9c7b73cfa8 --- /dev/null +++ b/packages/app/src/context/server-sync/connection.ts @@ -0,0 +1,22 @@ +import { createEffect, type Accessor } from "solid-js" +import type { ServerConnectionStatus } from "../server-sdk" + +export function createConnectionSync(input: { + status: Accessor + invalidate: () => void + connected: (info: { reconnect: boolean }) => void +}) { + createEffect(() => { + if (input.status() === "connected") return + input.invalidate() + }) + + let connectedOnce = false + function handleEvent(event: { type: string; directory: string }) { + if (event.directory !== "global" || event.type !== "server.connected") return + input.connected({ reconnect: connectedOnce }) + connectedOnce = true + } + + return { handleEvent } +} diff --git a/packages/app/src/hooks/use-integrations.ts b/packages/app/src/hooks/use-integrations.ts new file mode 100644 index 00000000000..b69e2244078 --- /dev/null +++ b/packages/app/src/hooks/use-integrations.ts @@ -0,0 +1,21 @@ +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { pathKey } from "@/utils/path-key" +import { createQuery } from "@tanstack/solid-query" +import type { Accessor } from "solid-js" + +export function useIntegrations(directory: Accessor) { + const serverSDK = useServerSDK() + const serverSync = useServerSync() + const query = createQuery(() => { + const value = directory() + return { + ...serverSync().queryOptions.integrations(value ? pathKey(value) : null), + enabled: serverSDK().connection.status() === "connected", + } + }) + + return { + list: () => (query.isSuccess || query.isRefetchError ? query.data : []), + } +} diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index 60ad01c8674..db6862c1d50 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -1,6 +1,4 @@ import { useServerSync } from "@/context/server-sync" -import { decode64 } from "@/utils/base64" -import { useParams } from "@solidjs/router" import { Iterable, pipe } from "effect" import { createEffect, createMemo, type Accessor } from "solid-js" import { selectProviderCatalog } from "./provider-catalog" @@ -19,8 +17,7 @@ const popularProviderSet = new Set(popularProviders) export function useProviders(directory: Accessor) { const serverSync = useServerSync() - const params = useParams() - const dir = () => (directory ? directory() : decode64(params.dir)) + const dir = directory const providers = () => { const value = dir() const projectStore = value ? serverSync().child(value)[0] : undefined @@ -39,6 +36,11 @@ export function useProviders(directory: Accessor) { } return { + ready: () => { + const value = dir() + if (!value) return false + return serverSync().child(value)[0].provider_ready + }, all: () => providers().all, default: () => providers().default, popular: () => diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index ac1fe1ff1e1..133678ddaeb 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -673,6 +673,17 @@ export const dict = { "session.error.notFound": "This session cannot be found", "session.error.notFound.description": "This tab points to a session that no longer exists on this server.", "session.error.notFound.closeTab": "Close Tab", + "session.background.moveTasks": "Move {{tasks}} to background", + "session.background.inBackground": "Running {{tasks}} in background", + "session.background.combine": "{{first}} and {{second}}", + "session.background.shell.one": "{{count}} shell", + "session.background.shell.other": "{{count}} shells", + "session.background.subagent.one": "{{count}} subagent", + "session.background.subagent.other": "{{count}} subagents", + "command.session.background": "Move to background", + "session.timeline.notice.finished": "{{actor}} finished", + "session.timeline.notice.failed": "{{actor}} failed", + "session.timeline.notice.cancelled": "{{actor}} cancelled", "session.error.serverConnection": "Can't connect to this server", "session.review.filesChanged": "Files Changed {{count}}", "session.review.change.one": "Change", diff --git a/packages/app/src/i18n/parity.test.ts b/packages/app/src/i18n/parity.test.ts deleted file mode 100644 index 50da0bb37d5..00000000000 --- a/packages/app/src/i18n/parity.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { desktopNativePluralCategories } from "./desktop-native" - -const appLocales = [ - "ar", - "br", - "bs", - "da", - "de", - "es", - "fr", - "ja", - "ko", - "no", - "pl", - "ru", - "uk", - "th", - "tr", - "zh", - "zht", - "hi", - "nl", - "id", - "vi", - "it", - "ur", - "pa", - "az", - "fi", - "sv", - "am", - "bg", - "bn", - "ca", - "cs", - "dv", - "dz", - "el", - "et", - "fa", - "fo", - "hr", - "hu", - "hy", - "is", - "ka", - "km", - "lo", - "lt", - "lv", - "mk", - "mn", - "ms", - "my", - "ne", - "ro", - "si", - "sk", - "sl", - "sq", - "sr", - "tg", - "tk", - "uz", -] as const -const desktopLocales = appLocales -const pluralCategories = new Map( - appLocales.map( - (locale) => - [ - locale, - desktopNativePluralCategories(locale).filter((category) => category !== "one" && category !== "other"), - ] as const, - ), -) -const appFallbackKeys = new Set([ - "dialog.provider.custom.label", - "dialog.model.unpaid.viewMoreProviders", - "session.header.reveal.finder", - "session.header.reveal.fileExplorer", - "session.header.reveal.containingFolder", - "command.session.export", - "command.session.export.description", - "context.export.session", - "toast.session.export.success.title", - "toast.session.export.success.description", - "toast.session.export.failed.title", - "toast.session.export.failed.description", - "common.export", - "settings.tab.preferences", - "settings.tab.notifications", - "settings.tab.projects", - "settings.tab.extensions", - "settings.preferences.description", - "settings.appearance.description", - "settings.notifications.description", - "settings.shortcuts.description", - "settings.servers.description", - "settings.projects.title", - "settings.projects.description", - "settings.projects.empty", - "settings.projects.server.all", - "settings.mcps.description", - "settings.extensions.description", - "settings.extensions.tab.mcps", - "settings.extensions.tab.skills", - "settings.extensions.availableAll", - "settings.extensions.manageConfig", - "settings.extensions.addSkills", - "settings.general.section.general", - "dialog.server.authenticate.title", - "project.settings.general.description", - "project.settings.scripts", - "project.settings.scripts.description", - "project.settings.extensions.description", - "project.settings.extensions.tab.lsps", - "project.settings.extensions.added", - "project.settings.extensions.shared", - "project.settings.extensions.lsp.detected", - "project.settings.extensions.lsp.description", - "project.settings.extensions.setupRequired", -]) - -const domains = [ - { - name: "app", - source: "./en.ts", - target: (locale: string) => `./${locale}.ts`, - locales: appLocales, - }, - { - name: "ui", - source: "../../../ui/src/i18n/en.ts", - target: (locale: string) => `../../../ui/src/i18n/${locale}.ts`, - locales: appLocales, - }, - { - name: "desktop", - source: "../../../desktop/src/renderer/i18n/en.ts", - target: (locale: string) => `../../../desktop/src/renderer/i18n/${locale}.ts`, - locales: desktopLocales, - }, -] as const - -describe("i18n parity", () => { - test("non-English locales have every English key and required plural variants", async () => { - for (const domain of domains) { - const source = await dictionary(domain.source) - for (const locale of domain.locales) { - const target = await dictionary(domain.target(locale)) - const missing = Object.keys(source).filter( - (key) => !Object.hasOwn(target, key) && (domain.name !== "app" || !appFallbackKeys.has(key)), - ) - const extra = Object.keys(target) - .filter((key) => !Object.hasOwn(source, key)) - .sort() - const expected = pluralFamilies(source) - .flatMap((key) => (pluralCategories.get(locale) ?? []).map((category) => `${key}.${category}`)) - .sort() - expect({ domain: domain.name, locale, missing, extra }).toEqual({ - domain: domain.name, - locale, - missing: [], - extra: expected, - }) - } - } - }) - - test("non-English locales preserve English placeholders", async () => { - for (const domain of domains) { - const source = await dictionary(domain.source) - for (const locale of domain.locales) { - const target = await dictionary(domain.target(locale)) - const mismatched = Object.keys(source).filter( - (key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(), - ) - const pluralMismatched = pluralFamilies(source).flatMap((key) => - (pluralCategories.get(locale) ?? []) - .map((category) => `${key}.${category}`) - .filter((variant) => placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join()), - ) - expect({ domain: domain.name, locale, mismatched, pluralMismatched }).toEqual({ - domain: domain.name, - locale, - mismatched: [], - pluralMismatched: [], - }) - } - } - }) - - test("non-English locales translate targeted unseen session keys", async () => { - const source = await dictionary("./en.ts") - for (const locale of appLocales) { - const target = await dictionary(`./${locale}.ts`) - for (const key of ["command.session.previous.unseen", "command.session.next.unseen"]) { - expect(target[key]).toBeDefined() - expect(target[key]).not.toBe(source[key]) - } - } - }) - - test("changed-file summary keys preserve rendered English copy and localize complete phrases", async () => { - const source = await dictionary("../../../ui/src/i18n/en.ts") - expect(source["ui.sessionTurn.diffs.changed.one"].replace("{{count}}", "1")).toBe("1 Changed file") - expect(source["ui.sessionTurn.diffs.changed.other"].replace("{{count}}", "2")).toBe("2 Changed files") - expect(source["ui.sessionTurn.diffs.changed"]).toBeUndefined() - - for (const locale of appLocales) { - const target = await dictionary(`../../../ui/src/i18n/${locale}.ts`) - for (const key of ["ui.sessionTurn.diffs.changed.one", "ui.sessionTurn.diffs.changed.other"]) { - expect(target[key].trim()).not.toBe("") - expect(placeholders(target[key])).toEqual(["count"]) - } - } - }) -}) - -describe("i18n plural parity", () => { - test("locale-specific categories exist and preserve count placeholders", async () => { - for (const domain of domains.slice(0, 2)) { - const source = await dictionary(domain.source) - const families = pluralFamilies(source) - for (const locale of domain.locales) { - const target = await dictionary(domain.target(locale)) - const missing = families.flatMap((key) => - (pluralCategories.get(locale) ?? []) - .map((category) => `${key}.${category}`) - .filter((variant) => !Object.hasOwn(target, variant)), - ) - const mismatched = families.flatMap((key) => - (pluralCategories.get(locale) ?? []) - .map((category) => `${key}.${category}`) - .filter( - (variant) => - Object.hasOwn(target, variant) && - placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join(), - ), - ) - expect({ domain: domain.name, locale, missing, mismatched }).toEqual({ - domain: domain.name, - locale, - missing: [], - mismatched: [], - }) - } - } - }) -}) - -async function dictionary(file: string) { - const module: unknown = await import(file) - if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) { - throw new Error(`Invalid translation dictionary: ${file}`) - } - return module.dict -} - -function isDictionary(value: unknown): value is Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) return false - return Object.values(value).every((item) => typeof item === "string") -} - -function placeholders(value: string) { - return Array.from(value.matchAll(/{{\s*([^}]+?)\s*}}/g), (match) => match[1]).sort() -} - -function pluralFamilies(dictionary: Record) { - return Object.keys(dictionary) - .filter( - (key) => - key.endsWith(".one") && - dictionary[key].includes("{{count}}") && - dictionary[`${key.slice(0, -4)}.other`]?.includes("{{count}}"), - ) - .map((key) => key.slice(0, -4)) -} diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 7df092aceb2..b050e687f06 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -25,6 +25,7 @@ export function DirectoryDataProvider( const params = useParams() const sync = useSync() const serverSync = useServerSync() + const language = useLanguage() const directory = () => props.directory const slug = createMemo(() => base64Encode(directory())) const href = (sessionID: string) => { @@ -44,8 +45,8 @@ export function DirectoryDataProvider( createResource( () => params.id, (id) => - sync() - .session.sync(id) + serverSync() + .session.hydrate(id) .catch(() => {}), ) diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx index 37926f1cf94..fb5a5431c2b 100644 --- a/packages/app/src/pages/home/home-sessions-controller.tsx +++ b/packages/app/src/pages/home/home-sessions-controller.tsx @@ -60,7 +60,7 @@ export function createHomeSessionsController(home: HomeController) { })) const sessionLoad = useQuery(() => ({ queryKey: homeSessions().indexKey, - enabled: !!home.server.focusedContext(), + enabled: home.server.focusedContext()?.sdk.connection.status() === "connected", queryFn: async ({ signal }) => { const ctx = home.server.focusedContext() if (!ctx) return { sessions: [], eventSequence: 0 } diff --git a/packages/app/src/pages/layout/project-avatar-state.ts b/packages/app/src/pages/layout/project-avatar-state.ts index 236f6bd4053..f603988ea28 100644 --- a/packages/app/src/pages/layout/project-avatar-state.ts +++ b/packages/app/src/pages/layout/project-avatar-state.ts @@ -2,7 +2,7 @@ import { createMemo, type Accessor } from "solid-js" import { useGlobal } from "@/context/global" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" -import { sessionPermissionRequest, sessionQuestionRequest } from "@/pages/session/composer/session-request-tree" +import { sessionPermissionRequest, sessionQuestionForm } from "@/pages/session/composer/session-request-tree" import { ServerConnection } from "@/context/server" export function useSessionTabAvatarState( @@ -31,7 +31,7 @@ export function useSessionTabAvatarState( const serverSync = sync() if (!serverSync) return false const [store] = serverSync.child(directory(), { bootstrap: false }) - return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId()) + return !!sessionQuestionForm(store.session, serverSync.session.data.form, sessionId()) }) const needsAttention = createMemo(() => hasPermissions() || hasQuestions()) const notificationState = createMemo(() => { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 8c5dd5b1485..fa595e21658 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -651,11 +651,18 @@ export default function Page() { }) const vcsKey = createMemo( () => - ["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const, + [ + serverSDK().scope, + "session-vcs", + sdk().directory, + sync().data.vcs?.branch ?? "", + sync().data.vcs?.default_branch ?? "", + ] as const, ) const vcsQuery = createQuery(() => { const mode = vcsMode() - const enabled = wantsReview() && sync().project?.vcs === "git" + const enabled = + serverSDK().connection.status() === "connected" && wantsReview() && sync().project?.vcs === "git" return { queryKey: [...vcsKey(), mode] as const, @@ -667,14 +674,17 @@ export default function Page() { sdk() .api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode }) .then((result) => result.data) - .catch((error) => { - console.debug("[session-review] failed to load vcs diff", { mode, error }) - return [] - }) : skipToken, } }) - const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100) + const refreshVcs = debounce(() => { + void queryClient.invalidateQueries({ queryKey: vcsKey() }) + }, 100) + onCleanup( + sdk().event.listen((event) => { + if (event.details.type === "filesystem.changed") refreshVcs() + }), + ) createEffect( on( () => desktopReviewOpen() || mobileChanges(), @@ -718,7 +728,7 @@ export default function Page() { const request = (scope: string, context?: number) => queryClient .fetchQuery({ - queryKey: [serverSDK().scope, ...vcsKey(), mode, "directory", scope, context, version] as const, + queryKey: [...vcsKey(), mode, "directory", scope, context, version] as const, staleTime: Number.POSITIVE_INFINITY, retry: 2, queryFn: () => @@ -1108,6 +1118,10 @@ export default function Page() { useComposerCommands() useSessionCommands({ session: controller, + background: { + blocking: () => composer.background.blocking().length > 0, + move: composer.background.move, + }, navigateMessageByOffset, setActiveMessage, focusInput, @@ -1469,6 +1483,8 @@ export default function Page() { working: () => true, overflowAnchor: "none", }) + const shouldAnchorBottom = () => + !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled() createEffect( on( () => controller.identity.params.id, @@ -2077,9 +2093,7 @@ export default function Page() { onUserScroll={markUserScroll} onHistoryScroll={onHistoryScroll} onAutoScrollInteraction={autoScroll.handleInteraction} - shouldAnchorBottom={ - !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled() - } + shouldAnchorBottom={shouldAnchorBottom()} centered={centered()} setContentRef={(el) => { content = el diff --git a/packages/app/src/pages/session/composer/session-background-dock.tsx b/packages/app/src/pages/session/composer/session-background-dock.tsx new file mode 100644 index 00000000000..976e21cf9d4 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-background-dock.tsx @@ -0,0 +1,86 @@ +import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { For, createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { SessionComposerPullout } from "./session-composer-pullout" + +export function SessionBackgroundDock(props: { + blocking: { type: "shell" | "subagent"; id?: string; label?: string }[] + tasks: { id: string; type: "shell" | "subagent"; label: string }[] + onBackground: () => void +}) { + const language = useLanguage() + const command = useCommand() + const [store, setStore] = createStore({ collapsed: true }) + const describe = (shells: number, subagents: number) => { + const shell = shells ? language.plural("session.background.shell", shells, { count: shells }) : undefined + const subagent = subagents + ? language.plural("session.background.subagent", subagents, { count: subagents }) + : undefined + if (shell && subagent) return language.t("session.background.combine", { first: shell, second: subagent }) + return shell ?? subagent ?? "" + } + const summary = createMemo(() => { + const shells = props.tasks.filter((task) => task.type === "shell").length + return describe(shells, props.tasks.length - shells) + }) + const moving = createMemo(() => { + const shells = props.blocking.filter((task) => task.type === "shell").length + const subagents = props.blocking.length - shells + const tasks = describe(shells, subagents) + return tasks ? language.t("session.background.moveTasks", { tasks }) : "" + }) + const background = createMemo(() => + summary() ? language.t("session.background.inBackground", { tasks: summary() }) : "", + ) + const blocking = () => props.blocking.length > 0 + const toggle = () => { + if (blocking()) { + props.onBackground() + return + } + setStore("collapsed", (value) => !value) + } + + return ( + + {blocking() && ( + + {moving()} + + + + + )} + {!!props.tasks.length && {background()}} + + } + ariaLabel={[moving(), background()].filter(Boolean).join(". ")} + multiline={blocking() && props.tasks.length > 0} + collapsed={blocking() || store.collapsed} + collapsible={!blocking()} + onToggle={toggle} + collapseLabel={language.t("session.todo.collapse")} + expandLabel={language.t("session.todo.expand")} + > +
+ + {(task) => ( +
+ + {language.t( + task.type === "shell" ? "ui.tool.shell" : "ui.tool.agent.default", + )} + + {task.label} +
+ )} +
+
+
+ ) +} diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index a9b0070bc02..7fa1c7a1e86 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -20,7 +20,7 @@ import { pathKey } from "@/utils/path-key" export function createPromptInputController(input: { sessionKey: Accessor sessionID: Accessor - queryOptions: Pick + queryOptions: Pick model?: ModelSelection }) { const layout = useLayout() @@ -30,8 +30,6 @@ export function createPromptInputController(input: { const providers = useProviders(() => sdk().directory) const view = layout.view(input.sessionKey) const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory))) - const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null)) - const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory))) return createMemo(() => { return { @@ -46,10 +44,7 @@ export function createPromptInputController(input: { model: { selection: input.model ?? local.model, paid: providers.paid().length > 0, - loading: - (local.agent.visible() && agentsQuery.isLoading) || - providersQuery.isLoading || - globalProvidersQuery.isLoading, + loading: (local.agent.visible() && agentsQuery.isLoading) || !providers.ready(), }, session: { id: input.sessionID(), diff --git a/packages/app/src/pages/session/composer/session-composer-pullout.tsx b/packages/app/src/pages/session/composer/session-composer-pullout.tsx new file mode 100644 index 00000000000..d17aaa7d87a --- /dev/null +++ b/packages/app/src/pages/session/composer/session-composer-pullout.tsx @@ -0,0 +1,157 @@ +import { DockTray } from "@opencode-ai/ui/dock-surface" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { useSpring } from "@opencode-ai/ui/motion-spring" +import { TextReveal } from "@opencode-ai/ui/text-reveal" +import { useSettings } from "@/context/settings" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { createEffect, createMemo, type JSX } from "solid-js" +import { createStore } from "solid-js/store" +import { Dynamic } from "solid-js/web" + +export function SessionComposerPullout(props: { + name: "todo" | "background" + label: JSX.Element + ariaLabel: string + preview?: string + multiline?: boolean + collapsed: boolean + collapsible?: boolean + onToggle: () => void + collapseLabel: string + expandLabel: string + dockProgress?: number + children: JSX.Element +}) { + const settings = useSettings() + const [store, setStore] = createStore({ height: 78, header: 42 }) + const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) + const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1))) + const shut = createMemo(() => 1 - dock()) + const value = createMemo(() => Math.max(0, Math.min(1, collapse()))) + const hide = createMemo(() => Math.max(value(), shut())) + const off = createMemo(() => hide() > 0.98) + const base = createMemo(() => Math.max(78, store.header + 36)) + const full = createMemo(() => Math.max(base(), store.height)) + let contentRef: HTMLDivElement | undefined + let headerRef: HTMLDivElement | undefined + + createEffect(() => { + const element = contentRef + const header = headerRef + if (!element || !header) return + const update = () => { + setStore("height", (height) => Math.max(height, element.scrollHeight)) + setStore("header", header.getBoundingClientRect().height) + } + update() + createResizeObserver([element, header], update) + }) + + return ( + +
+
{ + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + props.onToggle() + }} + > + + {props.label} + +
+ +
+ {props.collapsible !== false && ( +
+ { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + event.stopPropagation() + props.onToggle() + }} + aria-label={props.collapsed ? props.expandLabel : props.collapseLabel} + /> +
+ )} +
+
0.1 }} + style={{ visibility: off() ? "hidden" : "visible", opacity: `${Math.max(0, 1 - hide())}` }} + > + {props.children} +
+
+
+ ) +} diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 600ff41e3de..90ed32acdad 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -6,6 +6,7 @@ import { SessionQuestionDock } from "@/pages/session/composer/session-question-d import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock" import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" +import { SessionBackgroundDock } from "@/pages/session/composer/session-background-dock" import type { SessionComposerRegionController } from "./session-composer-region-controller" export function SessionComposerRegion(props: { @@ -15,6 +16,8 @@ export function SessionComposerRegion(props: { const language = useLanguage() const controller = props.controller const settings = useSettings() + const background = () => + controller.state.background.blocking().length > 0 || controller.state.background.tasks().length > 0 const rolled = () => { const revert = controller.revert() return revert?.items.length ? revert : undefined @@ -123,12 +126,21 @@ export function SessionComposerRegion(props: {
)} + +
+ void controller.state.background.move()} + /> +
+
diff --git a/packages/app/src/pages/session/composer/session-composer-state.test.ts b/packages/app/src/pages/session/composer/session-composer-state.test.ts index b0c462e7032..57dc54cde5c 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.test.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" -import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise" +import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise" import { todoDockAtBoundary, todoState } from "./session-composer-state" -import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" +import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree" const session = (input: { id: string; parentID?: string }) => ({ @@ -19,8 +19,10 @@ const question = (id: string, sessionID: string) => ({ id, sessionID, - questions: [], - }) as QuestionRequest + title: "Questions", + metadata: { kind: "question" }, + fields: [{ key: "q0", type: "string" }], + }) as FormInfo describe("sessionPermissionRequest", () => { test("prefers the current session permission", () => { @@ -80,7 +82,7 @@ describe("sessionPermissionRequest", () => { }) }) -describe("sessionQuestionRequest", () => { +describe("sessionQuestionForm", () => { test("prefers the current session question", () => { const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] const questions = { @@ -88,7 +90,7 @@ describe("sessionQuestionRequest", () => { child: [question("q-child", "child")], } - expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-root") + expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-root") }) test("returns a nested child question", () => { @@ -101,7 +103,16 @@ describe("sessionQuestionRequest", () => { grand: [question("q-grand", "grand")], } - expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand") + expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-grand") + }) + + test("skips forms that are not questions", () => { + const sessions = [session({ id: "root" })] + const forms = { + root: [{ ...question("form", "root"), metadata: { kind: "integration" } }], + } + + expect(sessionQuestionForm(sessions, forms, "root")).toBeUndefined() }) }) diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 8f3cc228226..113302f2575 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -1,15 +1,17 @@ import { createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import type { Todo } from "@/types" -import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise" +import type { FormInfo, PermissionRequest } from "@opencode-ai/client/promise" import { useParams } from "@solidjs/router" import { showToast } from "@/utils/toast" import { useServerSync } from "@/context/server-sync" +import { useServerSDK } from "@/context/server-sdk" import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" -import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" +import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree" +import { createQuery, useQueryClient } from "@tanstack/solid-query" export const todoState = (input: { count: number @@ -31,11 +33,33 @@ export function createSessionComposerController(options?: { closeMs?: number | ( const sdk = useSDK() const sync = useSync() const serverSync = useServerSync() + const serverSDK = useServerSDK() + const queryClient = useQueryClient() const language = useLanguage() const permission = usePermission() + const shellKey = () => [serverSDK().scope, sdk().directory, "shell"] as const + const shells = createQuery(() => ({ + queryKey: shellKey(), + enabled: !!params.id && serverSDK().connection.status() === "connected", + queryFn: () => + sdk() + .api.shell.list({ location: { directory: sdk().directory } }) + .then((result) => result.data ?? []), + })) + onCleanup( + sdk().event.listen((event) => { + if ( + event.details.type !== "shell.created" && + event.details.type !== "shell.exited" && + event.details.type !== "shell.deleted" + ) + return + void queryClient.invalidateQueries({ queryKey: shellKey(), exact: true }) + }), + ) - const questionRequest = createMemo((): QuestionRequest | undefined => { - return sessionQuestionRequest(sync().data.session, sync().data.question, params.id) + const questionRequest = createMemo((): FormInfo | undefined => { + return sessionQuestionForm(sync().data.session, serverSync().session.data.form, params.id) }) const permissionRequest = createMemo((): PermissionRequest | undefined => { @@ -61,6 +85,113 @@ export function createSessionComposerController(options?: { closeMs?: number | ( ) const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked()) + const primary = () => { + const id = params.id + return !!id && !serverSync().session.get(id)?.parentID + } + const backgroundBlocking = createMemo(() => { + if (!primary()) return [] + const id = params.id + if (!id) return [] + const assistant = (serverSync().session.data.session_message[id] ?? []).findLast( + (message) => message.type === "assistant" && message.time.completed === undefined, + ) + if (assistant?.type !== "assistant") return [] + return assistant.content.flatMap((part) => { + if (part.type !== "tool" || part.state.status !== "running") return [] + if (part.name !== "shell" && part.name !== "subagent") return [] + const value = part.name === "shell" ? part.state.metadata.shellID : part.state.metadata.sessionID + const label = part.name === "shell" ? part.state.input.command : part.state.input.description + return [ + { + type: part.name as "shell" | "subagent", + id: typeof value === "string" ? value : undefined, + label: typeof label === "string" ? label : undefined, + }, + ] + }) + }) + const backgroundTasks = createMemo(() => { + if (!primary()) return [] + const id = params.id + if (!id) return [] + const blocking = backgroundBlocking() + const messages = serverSync().session.data.session_message[id] ?? [] + const completed = new Set( + messages.flatMap((message) => { + if (message.type !== "synthetic") return [] + if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string") + return [message.metadata.childID] + if (message.metadata?.source === "shell" && typeof message.metadata.jobID === "string") + return [message.metadata.jobID] + return [] + }), + ) + const backgrounded = messages.flatMap((message) => { + if (message.type !== "assistant") return [] + return message.content.flatMap((part) => { + if (part.type !== "tool" || part.name !== "subagent") return [] + if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return [] + const sessionID = part.state.metadata.sessionID + if (typeof sessionID !== "string" || completed.has(sessionID)) return [] + const description = part.state.input.description + return [{ id: sessionID, type: "subagent" as const, label: typeof description === "string" ? description : sessionID }] + }) + }) + const active = Object.values(serverSync().session.data.info).flatMap((info) => { + if (info?.parentID !== id) return [] + if ((serverSync().session.data.session_status[info.id]?.type ?? "idle") === "idle") return [] + if ( + blocking.some( + (item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)), + ) + ) + return [] + return [{ id: info.id, type: "subagent" as const, label: info.title ?? info.id }] + }) + const backgroundShells = messages.flatMap((message) => { + if (message.type !== "assistant") return [] + return message.content.flatMap((part) => { + if (part.type !== "tool" || part.name !== "shell" || completed.has(part.id)) return [] + if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return [] + const shellID = part.state.metadata.shellID + const command = part.state.input.command + return [ + { + id: typeof shellID === "string" ? shellID : part.id, + type: "shell" as const, + label: typeof command === "string" ? command : part.id, + }, + ] + }) + }) + const running = (shells.isSuccess || shells.isRefetchError ? shells.data : []).flatMap((shell) => { + if (shell.status !== "running" || shell.metadata.sessionID !== id) return [] + if ( + blocking.some( + (item) => item.type === "shell" && (item.id === shell.id || (!!item.label && shell.command === item.label)), + ) + ) + return [] + return [{ id: shell.id, type: "shell" as const, label: shell.command }] + }) + return [ + ...new Map([...backgrounded, ...active, ...backgroundShells, ...running].map((task) => [task.id, task])).values(), + ] + }) + const moveToBackground = async () => { + if (!primary()) return + const sessionID = params.id + if (!sessionID) return + await sdk() + .api.session.background({ sessionID }) + .catch((error) => { + showToast({ + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }) + }) + } const [store, setStore] = createStore({ sessionID: params.id, @@ -191,6 +322,11 @@ export function createSessionComposerController(options?: { closeMs?: number | ( questionRequest, permissionRequest, permissionResponding, + background: { + blocking: backgroundBlocking, + tasks: backgroundTasks, + move: moveToBackground, + }, decide, todos, dock: () => diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 79c74260263..c3216627494 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -6,7 +6,7 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt" import { Icon } from "@opencode-ai/ui/icon" import { useSpring } from "@opencode-ai/ui/motion-spring" import { showToast } from "@/utils/toast" -import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/client/promise" +import type { FormAnswer, FormInfo, FormMultiselectField, FormStringField } from "@opencode-ai/client/promise" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" import { makeEventListener } from "@solid-primitives/event-listener" @@ -14,7 +14,13 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" import { useServerSDK } from "@/context/server-sdk" import { ScopedKey } from "@/utils/server-scope" -const cache = new Map() +const cache = new Map() + +type QuestionField = FormStringField | FormMultiselectField + +function questionField(field: FormInfo["fields"][number]): field is QuestionField { + return field.type === "string" || field.type === "multiselect" +} function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) { return ( @@ -61,19 +67,27 @@ function Option(props: { ) } -export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => { +export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => void }> = (props) => { const sdk = useSDK() const serverSDK = useServerSDK() const language = useLanguage() const cacheKey = ScopedKey.from(serverSDK().scope, props.request.id) - const questions = createMemo(() => props.request.questions) + const questions = createMemo(() => + props.request.fields.filter(questionField).map((field) => ({ + field, + header: field.title ?? "", + question: field.description ?? field.title ?? "", + options: field.type === "string" ? (field.options ?? []) : field.options, + multiple: field.type === "multiselect", + })), + ) const total = createMemo(() => questions().length) const cached = cache.get(cacheKey) const [store, setStore] = createStore({ tab: cached?.tab ?? 0, - answers: cached?.answers ?? ([] as QuestionAnswer[]), + answers: cached?.answers ?? ([] as string[][]), custom: cached?.custom ?? ([] as string[]), customOn: cached?.customOn ?? ([] as boolean[]), editing: false, @@ -157,7 +171,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit if (store.customOn[tab] === true) return list.length return Math.max( 0, - list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false), + list.findIndex((item) => store.answers[tab]?.includes(item.value) ?? false), ) } @@ -223,8 +237,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const replyMutation = useMutation(() => ({ - mutationFn: (answers: QuestionAnswer[]) => - sdk().api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers }), + mutationFn: (answer: FormAnswer) => + sdk().api.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }), onMutate: () => { props.onSubmit() }, @@ -236,7 +250,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit })) const rejectMutation = useMutation(() => ({ - mutationFn: () => sdk().api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }), + mutationFn: () => sdk().api.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }), onMutate: () => { props.onSubmit() }, @@ -249,17 +263,26 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending) - const reply = async (answers: QuestionAnswer[]) => { + const reply = (answer: FormAnswer) => { if (sending()) return - await replyMutation.mutateAsync(answers) + replyMutation.mutate(answer) } - const reject = async () => { + const reject = () => { if (sending()) return - await rejectMutation.mutateAsync() + rejectMutation.mutate() } - const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) + const submit = () => + reply( + Object.fromEntries( + questions().flatMap((question, index) => { + const answers = store.answers[index] ?? [] + if (answers.length === 0) return [] + return [[question.field.key, question.multiple ? answers : answers[0]]] + }), + ), + ) const answered = (i: number) => { if ((store.answers[i]?.length ?? 0) > 0) return true @@ -325,7 +348,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit if (event.key === "Escape") { event.preventDefault() - void reject() + reject() return } @@ -378,10 +401,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit if (!opt) return if (multi()) { setStore("editing", false) - toggle(opt.label) + toggle(opt.value) return } - pick(opt.label) + pick(opt.value) } const commitCustom = () => { @@ -549,7 +572,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit {(opt, i) => (