From 9314992f985b7bdaca0b78b0671e496edec020f3 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:11:58 +0800 Subject: [PATCH] refactor(app): remove v1 compatibility (#40382) --- bun.lock | 3 - packages/app/V1_API_MIGRATION.md | 220 ------ .../timeline-stability/fixture.test.ts | 4 - .../performance/timeline-stability/fixture.ts | 9 +- .../session-timeline-context-resize.spec.ts | 6 +- .../session-todo-dock-navigation.spec.ts | 22 +- .../regression/terminal-tab-switch.spec.ts | 2 +- packages/app/e2e/utils/mock-server.ts | 5 +- packages/app/e2e/utils/sse-transport.ts | 22 +- packages/app/package.json | 1 - packages/app/src/app.tsx | 20 - .../app/src/components/command-palette.ts | 2 +- .../components/dialog-command-palette-v2.tsx | 2 +- .../components/dialog-connect-provider.tsx | 10 +- .../src/components/dialog-custom-provider.tsx | 23 +- .../src/components/dialog-edit-project-v2.tsx | 2 +- .../src/components/dialog-edit-project.tsx | 2 +- packages/app/src/components/dialog-fork.tsx | 2 +- .../components/dialog-select-directory-v2.tsx | 32 +- .../components/dialog-select-directory.tsx | 28 +- .../src/components/dialog-select-server.tsx | 19 +- .../directory-picker-domain.test.ts | 4 +- .../src/components/directory-picker-domain.ts | 4 +- packages/app/src/components/edit-project.ts | 24 +- .../components/prompt-input/submit.test.ts | 49 +- .../app/src/components/prompt-input/submit.ts | 43 +- .../app/src/components/settings-general.tsx | 25 +- .../app/src/components/settings-providers.tsx | 19 +- .../src/components/settings-v2/general.tsx | 22 +- .../src/components/settings-v2/providers.tsx | 19 +- .../src/components/status-popover-body.tsx | 36 +- packages/app/src/components/terminal.tsx | 30 +- .../app/src/components/titlebar-tab-nav.tsx | 8 +- .../app/src/components/titlebar-tab-strip.tsx | 2 +- packages/app/src/components/titlebar.tsx | 2 +- packages/app/src/context/directory-sync.ts | 13 +- packages/app/src/context/file.tsx | 6 +- .../src/context/global-sync/bootstrap.test.ts | 145 +--- .../app/src/context/global-sync/bootstrap.ts | 35 +- .../src/context/global-sync/child-store.ts | 5 +- .../global-sync/home-session-index.test.ts | 18 +- .../context/global-sync/home-session-index.ts | 16 +- packages/app/src/context/global-sync/utils.ts | 15 +- packages/app/src/context/layout.tsx | 15 +- packages/app/src/context/permission.tsx | 2 - packages/app/src/context/sdk.tsx | 8 +- packages/app/src/context/server-sdk.test.ts | 175 +---- packages/app/src/context/server-sdk.tsx | 154 +---- .../app/src/context/server-session.test.ts | 302 +++++---- packages/app/src/context/server-session.ts | 181 ++--- packages/app/src/context/server-sync.test.ts | 3 +- packages/app/src/context/server-sync.tsx | 66 +- packages/app/src/context/terminal.tsx | 19 +- .../pages/home/home-projects-controller.tsx | 1 - .../app/src/pages/home/home-projects-view.tsx | 9 +- packages/app/src/pages/home/home-projects.tsx | 1 - .../home/home-session-search-controller.ts | 3 +- .../pages/home/home-sessions-controller.tsx | 11 +- .../app/src/pages/home/home-sessions-view.tsx | 13 +- packages/app/src/pages/home/home-sessions.tsx | 1 - packages/app/src/pages/layout.tsx | 96 +-- .../app/src/pages/layout/sidebar-items.tsx | 10 +- .../app/src/pages/layout/sidebar-project.tsx | 11 +- .../src/pages/layout/sidebar-workspace.tsx | 20 +- packages/app/src/pages/session.tsx | 33 +- packages/app/src/pages/session/review-tab.tsx | 4 +- .../session/timeline/message-timeline.tsx | 56 +- .../timeline/observe-element-offset.test.ts | 18 +- .../pages/session/use-session-commands.tsx | 43 +- .../src/pages/session/v2/review-panel-v2.tsx | 4 +- packages/app/src/types.ts | 170 +++-- packages/app/src/utils/persist.test.ts | 2 +- packages/app/src/utils/persist.ts | 2 +- packages/app/src/utils/server-compat.test.ts | 255 ------- packages/app/src/utils/server-compat.ts | 629 ------------------ packages/app/src/utils/server-health.test.ts | 28 +- packages/app/src/utils/server-health.ts | 7 +- .../app/src/utils/server-protocol.test.ts | 40 -- packages/app/src/utils/server-protocol.ts | 35 - packages/app/src/utils/server.ts | 24 - .../app/src/utils/session-message.test.ts | 25 - packages/app/src/utils/session-title.ts | 7 + .../src/utils/terminal-websocket-url.test.ts | 57 -- .../app/src/utils/terminal-websocket-url.ts | 22 +- .../app/test-browser/command-palette.test.ts | 2 +- 85 files changed, 829 insertions(+), 2711 deletions(-) delete mode 100644 packages/app/V1_API_MIGRATION.md delete mode 100644 packages/app/src/utils/server-compat.test.ts delete mode 100644 packages/app/src/utils/server-compat.ts delete mode 100644 packages/app/src/utils/server-protocol.test.ts delete mode 100644 packages/app/src/utils/server-protocol.ts create mode 100644 packages/app/src/utils/session-title.ts diff --git a/bun.lock b/bun.lock index 453b44f49b2..18583a1a5ca 100644 --- a/bun.lock +++ b/bun.lock @@ -64,7 +64,6 @@ "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", - "@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz", "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -6319,8 +6318,6 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], - "@opencode-ai/app/@opencode-ai/sdk": ["@opencode-ai/sdk@vendor/opencode-ai-sdk-1.18.8-dev.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-C2nfk4x0sPINwE5V6DPkFSuH3PkUmKPWHPzxpXC1j+3Ui5hslLCWJbkk8WcOG1Lyt3C0+yp4ea64v/kmtYCO4w=="], - "@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="], diff --git a/packages/app/V1_API_MIGRATION.md b/packages/app/V1_API_MIGRATION.md deleted file mode 100644 index 2850f107400..00000000000 --- a/packages/app/V1_API_MIGRATION.md +++ /dev/null @@ -1,220 +0,0 @@ -# V1 API Migration Checklist - -The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name. - -## Events - -- [x] Replace `GET /global/event` with `GET /api/event`. - - `src/context/server-sdk.tsx` -- [x] Reduce current granular session and message events into the existing app projections. - - `src/context/server-session-v2-reducer.ts` - - `src/context/server-session.ts` -- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`. - - `src/context/global-sync/event-reducer.ts` - - `src/context/server-session.ts` - - `src/context/notification.tsx` - - `src/pages/session/usage-exceeded-dialogs.tsx` -- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`. - - `src/context/global-sync/event-reducer.ts` - - `src/context/server-session.ts` -- [x] Adapt current permission and question events to the existing request model. - - `src/context/global-sync/event-reducer.ts` - - `src/context/permission.tsx` -- [x] Consume current file watcher events. - - `src/context/file.tsx` -- [x] Consume current VCS events. - - `src/context/global-sync/event-reducer.ts` - - `src/pages/session.tsx` -- [x] Consume current `pty.exited` events. - - `src/context/terminal.tsx` -- [ ] Migrate LSP and reference events. - - `src/context/global-sync/event-reducer.ts` - -## Sessions - -- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events. - - `src/context/server-sync.tsx` -- [x] Migrate session listing from `GET /session`. - - `src/context/server-sync.tsx` - - `src/context/directory-sync.ts` - - `src/pages/layout.tsx` -- [x] Migrate the remaining direct session read from `GET /session/:sessionID`. - - `src/components/titlebar.tsx` -- [x] Migrate session updates from `PATCH /session/:sessionID`. - - `src/context/directory-sync.ts` - - `src/context/layout.tsx` - - `src/pages/home.tsx` - - `src/pages/layout.tsx` - - `src/pages/session/timeline/message-timeline.tsx` - - `src/components/titlebar-tab-nav.tsx` - - Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`. -- [x] Migrate session deletion from `DELETE /session/:sessionID`. - - `src/pages/session/timeline/message-timeline.tsx` -- [x] Remove session diff loading from `GET /session/:sessionID/diff`. - - Historical Session diffs remain unavailable until the current API defines their snapshot semantics. -- [x] Migrate abort from `POST /session/:sessionID/abort`. - - `src/components/prompt-input/submit.ts` - - `src/pages/session/use-session-commands.tsx` - - `src/pages/session.tsx` -- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`. - - `src/pages/session/use-session-commands.tsx` - - `src/pages/session.tsx` -- [x] Replace `POST /session/:sessionID/summarize` with the current compact API. - - `src/pages/session/use-session-commands.tsx` -- [x] Migrate slash commands from `POST /session/:sessionID/command`. - - `src/components/prompt-input/submit.ts` -- [x] Migrate shell execution from `POST /session/:sessionID/shell`. - - `src/components/prompt-input/submit.ts` -- [x] Migrate session fork from `POST /session/:sessionID/fork`. - - `src/components/dialog-fork.tsx` -- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`. - - `src/pages/session/use-session-commands.tsx` - - `src/pages/session/timeline/message-timeline.tsx` - - Blocked: the current API has no sharing contract or implementation. - -## Session Compatibility Fallbacks - -These calls are retained as fallback adapters. The current production path supplies the current session and message APIs. - -- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary. - - `src/context/server-session.ts` -- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary. - - `src/context/server-session.ts` -- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary. - - `src/context/server-session.ts` - -## Filesystem - -- [ ] Migrate file listing from `GET /file`. - - `src/context/file.tsx` -- [ ] Migrate file reads from `GET /file/content`. - - `src/context/file.tsx` - - `src/pages/session/review-tab.tsx` - - `src/pages/session/v2/review-panel-v2.tsx` -- [x] Migrate path discovery from `GET /path` to `GET /api/path`. - - `src/context/global-sync/bootstrap.ts` - - `src/components/dialog-select-directory.tsx` - - `src/components/dialog-select-directory-v2.tsx` - -## Projects And Worktrees - -- [x] Migrate project listing from `GET /project` to `GET /api/project`. - - `src/context/global-sync/bootstrap.ts` -- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`. - - `src/context/global-sync/bootstrap.ts` -- [ ] Migrate Git initialization from `POST /project/git/init`. - - `src/pages/session.tsx` -- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`. - - `src/context/layout.tsx` - - `src/components/edit-project.ts` - - `src/pages/layout.tsx` -- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`. - - `src/pages/layout.tsx` - - `src/components/prompt-input/submit.ts` - - Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain. -- [ ] Migrate instance disposal from `POST /instance/dispose`. - - `src/pages/layout.tsx` - -## VCS - -- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`. - - `src/context/global-sync/bootstrap.ts` -- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`. - - `src/pages/session.tsx` -- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`. - - `src/pages/layout.tsx` - -## Configuration And Authentication - -- [ ] Migrate global configuration reads from `GET /global/config`. - - `src/context/global-sync/bootstrap.ts` -- [ ] Migrate directory configuration reads from `GET /config`. - - `src/context/global-sync/bootstrap.ts` -- [ ] Migrate global configuration updates from `PATCH /global/config`. - - `src/context/server-sync.tsx` -- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`. - - `src/components/dialog-connect-provider.tsx` -- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`. - - `src/components/dialog-connect-provider.tsx` -- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`. - - Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`. - - `src/components/dialog-connect-provider.tsx` - - `src/components/dialog-custom-provider.tsx` - - `src/components/settings-providers.tsx` - - `src/components/settings-v2/providers.tsx` -- [ ] Migrate global disposal from `POST /global/dispose`. - - `src/components/dialog-connect-provider.tsx` - - `src/components/settings-providers.tsx` - - `src/components/settings-v2/providers.tsx` - -## Permissions And Questions - -- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`. - - `src/context/global-sync/bootstrap.ts` - - `src/context/permission.tsx` -- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`. - - `src/context/permission.tsx` - - `src/pages/session/composer/session-composer-state.ts` -- [x] Migrate question listing from `GET /question` to `GET /api/question/request`. - - `src/context/global-sync/bootstrap.ts` -- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`. - - `src/pages/session/composer/session-question-dock.tsx` - -## Commands, MCP, LSP, And References - -- [x] Migrate command listing from `GET /command` to `GET /api/command`. - - `src/context/global-sync/bootstrap.ts` - - `src/context/server-sync.tsx` -- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`. - - `src/context/server-sync.tsx` -- [ ] Replace legacy MCP authentication with the Integration OAuth workflow. - - `src/context/server-sync.tsx` -- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`. - - `src/context/server-sync.tsx` -- [ ] Migrate LSP status from `GET /lsp`. - - `src/context/server-sync.tsx` -- [x] Move `GET /api/reference` off the legacy generated SDK transport. - - `src/context/global-sync/bootstrap.ts` - -## Search - -- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`. - - `src/components/command-palette.ts` - - `src/components/dialog-command-palette-v2.tsx` - -## PTY And Terminal - -- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`. - - `src/context/terminal.tsx` - - `src/components/terminal.tsx` -- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`. - - `src/components/settings-general.tsx` - - `src/components/settings-v2/general.tsx` -- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`. - - `src/components/terminal.tsx` -- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`. - - `src/components/terminal.tsx` - -## Legacy Types And Adapters - -These are not V1 network requests, but they keep the UI coupled to V1 data contracts. - -- [ ] Replace the current-session-to-legacy-session adapter. - - `src/utils/session.ts` -- [ ] Replace the current-message-to-legacy-message-and-part adapter. - - `src/utils/session-message.ts` -- [ ] Replace current agent, provider, and model adapters to legacy SDK structures. - - `src/context/global-sync/utils.ts` -- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering. -- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone. - - `package.json` - -## Test Infrastructure - -- [ ] Replace V1 endpoint mocks with current API mocks. - - `e2e/utils/mock-server.ts` -- [x] Replace `/global/event` and `/event` interception with current event transport handling. - - `e2e/utils/sse-transport.ts` -- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests. - - `e2e/performance/timeline-stability/fixture.ts` -- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests. diff --git a/packages/app/e2e/performance/timeline-stability/fixture.test.ts b/packages/app/e2e/performance/timeline-stability/fixture.test.ts index 1f9c43d2c7b..b003645e640 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.test.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.test.ts @@ -45,10 +45,6 @@ describe("timeline fixture validation", () => { expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/) expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1) }) - - test("uses the projected tool ID as its call ID", () => { - expect(toolPart("call_1", "read", "running", {})).toMatchObject({ id: "call_1", callID: "call_1" }) - }) }) if (false) { diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index 10d34aeb8a9..c189b71e88e 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -2,15 +2,8 @@ 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 { - AssistantMessage, - Message, - Part, - ToolPart, - ToolState, - UserMessage, -} from "../../../src/types" import type { SessionV1Info, 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" import { mockOpenCodeServer } from "../../utils/mock-server" diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index a9a4738da92..a8cac39490f 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -14,8 +14,8 @@ const projectID = "proj_context_resize_regression" const sessionID = "ses_context_resize_regression" const title = "Context resize regression" const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } -const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"] -const followingTextID = "prt_0104_text" +const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"] +const followingTextID = `${id("msg_assistant", 10)}:text:0` type Message = { info: Record & { id: string; role: "user" | "assistant" } @@ -295,7 +295,7 @@ function contextTool( sessionID, messageID, type: "tool", - callID: `call_${partID}`, + callID: partID, tool, state: { status, diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts index 55e71212753..c0fa157c6eb 100644 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -23,7 +23,7 @@ type EventPayload = { test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" }) -test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => { +test("animates todo opening without replaying it across session tabs", async ({ page }) => { test.setTimeout(90_000) const events: EventPayload[] = [] const todos: Record = { [sourceID]: [], [otherID]: [] } @@ -86,28 +86,8 @@ test("animates todo lifecycle without replaying it across session tabs", async ( await switchSession(page, otherID, otherTitle) await expect(dock).toHaveCount(0) - const returningOpen = sampleDock(page, 700) - await switchSession(page, sourceID, sourceTitle) - const openSamples = (await returningOpen).filter((sample) => sample.present) - expect(openSamples.length).toBeGreaterThan(0) - expect(openSamples[0]!.opacity).toBeGreaterThan(0.98) - expect(openSamples[0]!.height).toBeGreaterThan(70) - await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1) - - const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" })) - const closing = sampleDock(page, 1_000) - todos[sourceID] = completedTodos - events.push(todoEvent(sourceID, completedTodos)) - await expect(dock).toHaveCount(0) - expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true) - todos[sourceID] = [] - events.push(todoEvent(sourceID, [])) - - await switchSession(page, otherID, otherTitle) - const returningEmpty = sampleDock(page, 700) await switchSession(page, sourceID, sourceTitle) await expect(dock).toHaveCount(0) - expect((await returningEmpty).every((sample) => !sample.present)).toBe(true) }) function session(id: string, title: string, created: number) { diff --git a/packages/app/e2e/regression/terminal-tab-switch.spec.ts b/packages/app/e2e/regression/terminal-tab-switch.spec.ts index b4932158261..5b8d53affaa 100644 --- a/packages/app/e2e/regression/terminal-tab-switch.spec.ts +++ b/packages/app/e2e/regression/terminal-tab-switch.spec.ts @@ -32,7 +32,7 @@ test("keeps the terminal session alive when switching session tabs in a workspac const connection = new URL(connections[0]!) expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`) expect(connection.searchParams.get("location[directory]")).toBe(directory) - expect(connection.searchParams.get("ticket")).toBe("e2e-ticket") + expect(connection.searchParams.get("ticket")).toBeNull() await writeProbe(page) await switchTab(page, titleB) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index a067a22369b..6c6d114d46a 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -80,9 +80,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { ) } if (path === "/global/health") - return config.protocol === "v2" ? json(route, {}) : json(route, { healthy: true }) - if (path === "/api/health" && config.protocol === "v2") - return json(route, { healthy: true, version: "2.0.0", pid: 1 }) + return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true }) + if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 }) if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) if (path === "/provider") return json(route, providerConfig(config)) if (path === "/provider/auth") return json(route, config.integrationMethods ?? {}) diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index b0e3b74c6d9..a245082bc71 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -3,7 +3,7 @@ import type { Page } from "@playwright/test" export type SseConnectionRecord = { id: number url: string - path: "/global/event" | "/event" | "/api/event" + path: "/api/event" headers: Record openedAt: number endedAt?: number @@ -174,10 +174,7 @@ export async function installSseTransport( const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) const url = new URL(request.url) - if ( - url.origin !== server || - (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event") - ) + if (url.origin !== server || url.pathname !== "/api/event") return originalFetch(request) const id = ++nextConnectionID @@ -193,18 +190,9 @@ export async function installSseTransport( record.controller = controller connections.push(record) if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) - if (url.pathname === "/api/event") - controller.enqueue( - encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), - ) - if (url.pathname === "/global/event") - controller.enqueue( - encoder.encode( - frame({ - payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} }, - }), - ), - ) + controller.enqueue( + encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), + ) request.signal.addEventListener( "abort", () => { diff --git a/packages/app/package.json b/packages/app/package.json index 06a91d5efe8..21096c7e7fd 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -56,7 +56,6 @@ "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", - "@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz", "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index f47c432e420..c2da99d976a 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -238,26 +238,6 @@ function UiI18nBridge(props: ParentProps) { } function LayoutCompatibility(props: ParentProps) { - const global = useGlobal() - const navigate = useNavigate() - const server = useServer() - const settings = useSettings() - - createEffect(() => { - if (settings.general.newLayoutDesigns()) return - const current = server.current - if (!current) return - const protocol = global.ensureServerCtx(current).sdk.protocolKind() - if (protocol !== "v2") return - const next = global.servers.list().find((s) => { - if (ServerConnection.key(s) === ServerConnection.key(current)) return false - return global.ensureServerCtx(s).sdk.protocolKind() !== "v2" - }) - if (!next) return - navigate("/") - queueMicrotask(() => server.setActive(ServerConnection.key(next))) - }) - return <>{props.children} } diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts index f7757374124..5779c3a423f 100644 --- a/packages/app/src/components/command-palette.ts +++ b/packages/app/src/components/command-palette.ts @@ -146,7 +146,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on server: ServerConnection.key(serverSDK.server), opened: serverCtx.projects.list, stored: () => serverCtx.sync.data.project, - load: (search, signal) => serverSDK.currentApi.session.list({ parentID: null, search, limit: 50 }, { signal }), + load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) diff --git a/packages/app/src/components/dialog-command-palette-v2.tsx b/packages/app/src/components/dialog-command-palette-v2.tsx index dbf966cf52a..e996fd0be77 100644 --- a/packages/app/src/components/dialog-command-palette-v2.tsx +++ b/packages/app/src/components/dialog-command-palette-v2.tsx @@ -79,7 +79,7 @@ export function DialogHomeCommandPaletteV2(props: { server: ServerConnection.key(props.server), opened: serverCtx.projects.list, stored: () => serverCtx.sync.data.project, - load: (search, signal) => serverCtx.sdk.currentApi.session.list({ parentID: null, search, limit: 50 }, { signal }), + load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 615640089fc..8291bfc9474 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -418,7 +418,7 @@ function ProviderConnection(props: { () => ({ provider: props.provider, directory: directory() }), (input) => serverSDK() - .currentApi.integration.get({ + .api.integration.get({ integrationID: input.provider, location: input.directory ? { directory: input.directory } : undefined, }) @@ -547,7 +547,7 @@ function ProviderConnection(props: { } dispatch({ type: "auth.pending" }) await serverSDK() - .currentApi.integration.oauth.connect({ + .api.integration.oauth.connect({ integrationID: props.provider, methodID: method.id, inputs: inputs ?? {}, @@ -816,7 +816,7 @@ function ProviderConnection(props: { } setFormStore("error", undefined) - await serverSDK().currentApi.integration.connect.key({ + await serverSDK().api.integration.connect.key({ integrationID: props.provider, location: location(), key: apiKey, @@ -947,7 +947,7 @@ function ProviderConnection(props: { setFormStore("error", undefined) const result = await serverSDK() - .currentApi.integration.oauth.complete({ + .api.integration.oauth.complete({ integrationID: props.provider, attemptID: store.authorization!.attemptID, location: location(), @@ -1044,7 +1044,7 @@ function ProviderConnection(props: { const authorization = store.authorization if (!authorization || !alive.value) return const result = await serverSDK() - .currentApi.integration.oauth.status({ + .api.integration.oauth.status({ integrationID: props.provider, attemptID: authorization.attemptID, location: location(), diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index af476f38b79..cb6b531c61f 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -130,26 +130,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) { } const saveMutation = useMutation(() => ({ - mutationFn: async (result: NonNullable>) => { - if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server") - const disabledProviders = serverSync().data.config.disabled_providers ?? [] - const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) - - if (result.key) { - await serverSDK().legacy.auth.set({ - providerID: result.providerID, - auth: { - type: "api", - key: result.key, - }, - }) - } - - await serverSync().updateConfig({ - provider: { [result.providerID]: result.config }, - disabled_providers: nextDisabled, - }) - return result + mutationFn: async (result: NonNullable>): Promise => { + // TODO: Restore custom providers when V2 exposes config and arbitrary credential APIs. + throw new Error(`Custom provider ${result.providerID} is unavailable`) }, onSuccess: (result) => { dialog.close() diff --git a/packages/app/src/components/dialog-edit-project-v2.tsx b/packages/app/src/components/dialog-edit-project-v2.tsx index dc9714aa062..77adec7b1ca 100644 --- a/packages/app/src/components/dialog-edit-project-v2.tsx +++ b/packages/app/src/components/dialog-edit-project-v2.tsx @@ -146,7 +146,7 @@ export function DialogEditProjectV2(props: { project: LocalProject; server: Serv {language.t("common.cancel")} - + {model.save.isPending ? language.t("common.saving") : language.t("common.save")} diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 86a9630359b..58c2a08956a 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -160,7 +160,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server - diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 86c3a21e2c8..6fe0c445601 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -69,7 +69,7 @@ export const DialogFork: Component = () => { const dir = base64Encode(sdk().directory) sdk() - .currentApi.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } }) + .api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } }) .then((forked) => { dialog.close() prompt.set(restored, undefined, { dir, id: forked.id }) diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index ada42b7d83e..e3bf4c19af7 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -66,23 +66,21 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { let pathArea: HTMLDivElement | undefined let navigation = 0 - const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) const [fallbackPath] = createResource( - () => (missingBase() ? true : undefined), - async (): Promise => { - if ((await sdk.protocol) === "v1") - return sdk.legacy.path.get().catch(() => undefined) - return sdk.api.location + () => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined), + () => + sdk.api.location .get() - .then((location) => ({ - state: "", - config: "", - worktree: location.project.directory, - directory: location.directory, - home: "", - })) - .catch(() => undefined) - }, + .then( + (location): Path => ({ + state: "", + config: "", + worktree: location.project.directory, + directory: location.directory, + home: "", + }), + ) + .catch(() => undefined), { initialValue: undefined }, ) const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") @@ -104,7 +102,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } const base = pickerRoot(cleaned) || root() || start() if (!base) return { query: value, items: directories.slice(0, 5) } - const files = await sdk.currentApi.file + const files = await sdk.api.file .find({ location: { directory: base }, query: pickerFileSearchQuery(base, value, home()), @@ -134,7 +132,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { existing ?? loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) - return sdk.currentApi.file + return sdk.api.file .list({ location: { directory: absolute } }) .then((result) => result.data.map((entry) => ({ diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index a6b1894a839..33c8a73d41e 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -57,23 +57,21 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const [filter, setFilter] = createSignal("") let list: ListRef | undefined - const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) const [fallbackPath] = createResource( - () => (missingBase() ? true : undefined), - async (): Promise => { - if ((await sdk.protocol) === "v1") - return sdk.legacy.path.get().catch(() => undefined) - return sdk.api.location + () => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined), + () => + sdk.api.location .get() - .then((location) => ({ - state: "", - config: "", - worktree: location.project.directory, - directory: location.directory, - home: "", - })) - .catch(() => undefined) - }, + .then( + (location): Path => ({ + state: "", + config: "", + worktree: location.project.directory, + directory: location.directory, + home: "", + }), + ) + .catch(() => undefined), { initialValue: undefined }, ) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 23102204c6d..aa914775e0b 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -16,7 +16,6 @@ import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" -import { detectServerProtocol } from "@/utils/server-protocol" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { useSettings } from "@/context/settings" import { useTabs } from "@/context/tabs" @@ -264,13 +263,6 @@ export function useServerManagementController(options: { onSelect?: () => void; setStore("addServer", { error: language.t("dialog.server.add.error") }) return } - if ( - !settings.general.newLayoutDesigns() && - (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2" - ) { - setStore("addServer", { error: language.t("dialog.server.add.error") }) - return - } resetAdd() if (options.navigateOnAdd === false) { @@ -315,13 +307,6 @@ export function useServerManagementController(options: { onSelect?: () => void; setStore("editServer", { error: language.t("dialog.server.add.error") }) return } - if ( - !settings.general.newLayoutDesigns() && - (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2" - ) { - setStore("editServer", { error: language.t("dialog.server.add.error") }) - return - } if (normalized === input.original.http.url) { server.add(conn) } else { @@ -360,9 +345,7 @@ export function useServerManagementController(options: { onSelect?: () => void; const sortedItems = createMemo(() => { const raw = items() - const list = settings.general.newLayoutDesigns() - ? raw - : raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2") + const list = raw if (!list.length) return list const active = current() const order = new Map(list.map((url, index) => [url, index] as const)) diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts index 26fb9f2ffef..1bc9af08334 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -133,7 +133,7 @@ test("scopes file autocomplete to the current browser root", () => { test("resolves directory autocomplete from the current browser root", async () => { const directories: string[] = [] const sdk = { - currentApi: { + api: { file: { find: (input: { location?: { directory?: string } }) => { directories.push(input.location?.directory ?? "") @@ -155,7 +155,7 @@ test("resolves directory autocomplete from the current browser root", async () = test("searches from an absolute root without a default base", async () => { const directories: string[] = [] const sdk = { - currentApi: { + api: { file: { list: (input: { location?: { directory?: string } }) => { directories.push(input.location?.directory ?? "") diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index 075b16a3144..9539ae1d01d 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -342,7 +342,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const key = trimPickerPath(directory) const existing = cache.get(key) if (existing) return existing - const request = args.sdk.currentApi.file + const request = args.sdk.api.file .list({ location: { directory: key } }) .then((result) => result.data) .catch(() => []) @@ -374,7 +374,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") const query = normalizePickerDrive(input.path) if (!pathInput) { - const results = await args.sdk.currentApi.file + const results = await args.sdk.api.file .find({ location: { directory: input.directory }, query, type: "directory", limit: 50 }) .then((result) => result.data.map((entry) => entry.path)) .catch(() => []) diff --git a/packages/app/src/components/edit-project.ts b/packages/app/src/components/edit-project.ts index efbf80840d4..16b54fc72d2 100644 --- a/packages/app/src/components/edit-project.ts +++ b/packages/app/src/components/edit-project.ts @@ -9,6 +9,7 @@ import { type LocalProject } from "@/context/layout" import { ServerConnection } from "@/context/server" export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) { + const supported = !props.project.id || props.project.id === "global" const dialog = useDialog() const global = useGlobal() const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) @@ -71,23 +72,9 @@ export function createEditProjectModel(props: { project: LocalProject; server: S const start = store.startup.trim() if (props.project.id && props.project.id !== "global") { - if ((await serverCtx().sdk.protocol) !== "v1") return - const project = await serverCtx() - .sdk.legacy.project.update({ - projectID: props.project.id, - directory: props.project.worktree, - name, - icon: { color: store.color || "", override: store.iconOverride || "" }, - commands: { start }, - }) - .then((result) => result.data) - if (!project) return - serverCtx().sync.set("project", (items) => - items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)), - ) - serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined) - dialog.close() - return + // TODO: Restore project edits when the V2 client exposes a project update API. + // await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands }) + throw new Error(`Project ${props.project.id} cannot be updated`) } serverCtx().sync.project.meta(props.project.worktree, { @@ -101,7 +88,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S function submit(event: SubmitEvent) { event.preventDefault() - if (save.isPending) return + if (!supported || save.isPending) return save.mutate() } @@ -111,6 +98,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S folderName, defaultName, save, + supported, submit, drop, dragOver, diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index e79b6617e07..0cfa52d80d0 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -31,6 +31,12 @@ const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string const sentPrompts: string[] = [] const promptInputs: unknown[] = [] const sentCommands: unknown[] = [] +const switchedAgents: Array<{ sessionID: string; agent: string }> = [] +const switchedModels: Array<{ + sessionID: string + model: { id: string; providerID: string; variant?: string } +}> = [] +const sessionRequestOrder: string[] = [] const commands: Array<{ name: string }> = [] let serverSessionSyncs = 0 @@ -93,10 +99,22 @@ const clientFor = (directory: string) => { } }, prompt: async (input: unknown) => { + sessionRequestOrder.push("prompt") sentPrompts.push(directory) promptInputs.push(input) return { data: undefined } }, + switchAgent: async (input: { sessionID: string; agent: string }) => { + sessionRequestOrder.push("agent") + switchedAgents.push(input) + }, + switchModel: async (input: { + sessionID: string + model: { id: string; providerID: string; variant?: string } + }) => { + sessionRequestOrder.push("model") + switchedModels.push(input) + }, command: async (input: unknown) => { sentCommands.push(input) }, @@ -125,13 +143,6 @@ beforeAll(async () => { useSearchParams: () => [search, () => undefined], })) - mock.module("@opencode-ai/sdk/v2/client", () => ({ - createOpencodeClient: (input: { directory: string }) => { - createdClients.push(input.directory) - return clientFor(input.directory) - }, - })) - mock.module("@opencode-ai/ui/toast", () => ({ Toast: { Region: () => null }, showToast: () => 0, @@ -197,13 +208,8 @@ beforeAll(async () => { const sdk = { scope: "local", directory: "/repo/main", - client: rootClient, api: rootClient.api, - currentApi: rootClient.api, url: "http://localhost:4096", - createClient(opts: any) { - return clientFor(opts.directory) - }, } return () => sdk }, @@ -291,6 +297,9 @@ beforeEach(() => { sentPrompts.length = 0 promptInputs.length = 0 sentCommands.length = 0 + switchedAgents.length = 0 + switchedModels.length = 0 + sessionRequestOrder.length = 0 commands.length = 0 promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] params = {} @@ -448,13 +457,17 @@ describe("prompt submit worktree selection", () => { expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }]) }) - test("includes the selected variant on optimistic prompts", async () => { + test("switches the selected agent and model before prompting", async () => { params = { id: "session-1" } variant = "high" const submit = createPromptSubmit({ prompt, - info: () => ({ id: "session-1" }), + info: () => ({ + id: "session-1", + agent: "old-agent", + model: { id: "old-model", providerID: "old-provider" }, + }), imageAttachments: () => [], commentCount: () => 0, autoAccept: () => false, @@ -483,6 +496,14 @@ describe("prompt submit worktree selection", () => { }, }) expect(sentPrompts).toEqual(["/repo/main"]) + expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }]) + expect(switchedModels).toEqual([ + { + sessionID: "session-1", + model: { id: "model", providerID: "provider", variant: "high" }, + }, + ]) + expect(sessionRequestOrder).toEqual(["agent", "model", "prompt"]) expect(promptInputs[0]).toMatchObject({ sessionID: "session-1", text: "ls", diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index e001a63987f..56f81acf58f 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -15,6 +15,7 @@ import { useSDK, type DirectorySDK } from "@/context/sdk" import { useSync, type DirectorySync } from "@/context/sync" import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" +import { getDirectory } from "@opencode-ai/core/util/path" import { buildRequestParts } from "./build-request-parts" import { setCursorPosition } from "./editor-dom" import { formatServerError } from "@/utils/server-errors" @@ -22,7 +23,6 @@ import { ScopedKey } from "@/utils/server-scope" import { createPromptSubmissionState } from "./submission-state" import { normalizeSessionInfo } from "@/utils/session" import { Event } from "@opencode-ai/schema/event" -import { getDirectory } from "@opencode-ai/core/util/path" type PendingPrompt = { abort: AbortController @@ -42,9 +42,10 @@ export type FollowupDraft = { } type FollowupSendInput = { - api: DirectorySDK["currentApi"]["session"] + api: DirectorySDK["api"]["session"] serverSync: ServerSync sync: DirectorySync + session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined> draft: FollowupDraft messageID?: string optimisticBusy?: boolean @@ -157,6 +158,25 @@ export async function sendFollowupDraft(input: FollowupSendInput) { return false } + const session = input.session() + if (session?.agent !== input.draft.agent) { + await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent }) + } + if ( + session?.model?.providerID !== input.draft.model.providerID || + session.model.id !== input.draft.model.modelID || + (session.model.variant ?? "default") !== (input.draft.variant ?? "default") + ) { + await input.api.switchModel({ + sessionID: input.draft.sessionID, + model: { + id: input.draft.model.modelID, + providerID: input.draft.model.providerID, + variant: input.draft.variant, + }, + }) + } + await input.api.prompt({ sessionID: input.draft.sessionID, id: messageID, @@ -197,7 +217,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) { type PromptSubmitInput = { prompt: ReturnType - info: Accessor<{ id: string } | undefined> + info: Accessor< + { id: string; agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined + > imageAttachments: Accessor commentCount: Accessor autoAccept: Accessor @@ -261,7 +283,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { return Promise.resolve() } return sdk() - .currentApi.session.interrupt({ sessionID }) + .api.session.interrupt({ sessionID }) .catch(() => {}) } @@ -345,11 +367,10 @@ export function createPromptSubmit(input: PromptSubmitInput) { const worktreeSelection = input.newSessionWorktree?.() || "main" let sessionDirectory = projectDirectory - if (isNewSession) { if (worktreeSelection === "create") { const createdWorktree = await sdk() - .currentApi.projectCopy.create({ + .api.projectCopy.create({ projectID: sync().data.project, strategy: "git_worktree", directory: getDirectory(projectDirectory), @@ -362,7 +383,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) return undefined }) - if (!createdWorktree) return WorktreeState.pending(sdk().scope, createdWorktree.directory) sessionDirectory = createdWorktree.directory @@ -382,7 +402,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { let session = input.info() if (!session && isNewSession) { const created = await sdk() - .currentApi.session.create({ + .api.session.create({ agent: currentAgent.name, model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, location: { directory: sessionDirectory }, @@ -473,7 +493,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { clearInput() const eventID = Event.ID.create() sdk() - .currentApi.session.shell({ + .api.session.shell({ sessionID: session.id, id: eventID, command: text, @@ -497,7 +517,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const messageID = Identifier.ascending("message") serverSync().session.set("session_status", session.id, { type: "busy" }) sdk() - .currentApi.session.command({ + .api.session.command({ sessionID: session.id, id: messageID, command: commandName, @@ -594,9 +614,10 @@ export function createPromptSubmit(input: PromptSubmitInput) { } void sendFollowupDraft({ - api: sdk().currentApi.session, + api: sdk().api.session, sync: sync(), serverSync: serverSync(), + session: () => input.info() ?? session, draft, messageID, optimisticBusy: sessionDirectory === projectDirectory, diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 18a2945c963..b238d98795f 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -13,7 +13,7 @@ import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" import { usePlatform, type DisplayBackend } from "@/context/platform" import { useServerSync } from "@/context/server-sync" -import { useServerProtocol, useServerSDK } from "@/context/server-sdk" +import { useServerSDK } from "@/context/server-sdk" import { useUpdaterAction } from "./updater-action" import { monoDefault, @@ -125,11 +125,12 @@ export const SettingsGeneral: Component = () => { const serverSync = useServerSync() const serverSdk = useServerSDK() - const protocol = useServerProtocol() const [shells] = createResource( - () => (protocol() === "v1" ? serverSdk() : undefined), - (sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]), + async () => { + // TODO: Restore executable shell discovery; V2 shell.list only lists shell processes. + return [] as ShellOption[] + }, { initialValue: [] as ShellOption[] }, ) @@ -320,13 +321,13 @@ export const SettingsGeneral: Component = () => { - - +