mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d06546b7ea | |||
| 2893d3f55c | |||
| aee34c9383 | |||
| 5d351406a1 | |||
| 86d90eb4ef | |||
| 31d1fae1e7 |
@@ -64,7 +64,6 @@
|
|||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/schema": "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/session-ui": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -6333,8 +6332,6 @@
|
|||||||
|
|
||||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
"@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/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=="],
|
"@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=="],
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -45,10 +45,6 @@ describe("timeline fixture validation", () => {
|
|||||||
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
|
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
|
||||||
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
|
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) {
|
if (false) {
|
||||||
|
|||||||
@@ -2,15 +2,8 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
|||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
||||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
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 { 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 { expect, type Page } from "@playwright/test"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type EventPayload = {
|
|||||||
|
|
||||||
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
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)
|
test.setTimeout(90_000)
|
||||||
const events: EventPayload[] = []
|
const events: EventPayload[] = []
|
||||||
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||||
@@ -86,28 +86,8 @@ test("animates todo lifecycle without replaying it across session tabs", async (
|
|||||||
await switchSession(page, otherID, otherTitle)
|
await switchSession(page, otherID, otherTitle)
|
||||||
await expect(dock).toHaveCount(0)
|
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 switchSession(page, sourceID, sourceTitle)
|
||||||
await expect(dock).toHaveCount(0)
|
await expect(dock).toHaveCount(0)
|
||||||
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function session(id: string, title: string, created: number) {
|
function session(id: string, title: string, created: number) {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ test("keeps the terminal session alive when switching session tabs in a workspac
|
|||||||
const connection = new URL(connections[0]!)
|
const connection = new URL(connections[0]!)
|
||||||
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
|
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
|
||||||
expect(connection.searchParams.get("location[directory]")).toBe(directory)
|
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 writeProbe(page)
|
||||||
|
|
||||||
await switchTab(page, titleB)
|
await switchTab(page, titleB)
|
||||||
|
|||||||
@@ -80,9 +80,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (path === "/global/health")
|
if (path === "/global/health")
|
||||||
return config.protocol === "v2" ? json(route, {}) : json(route, { healthy: true })
|
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
|
||||||
if (path === "/api/health" && config.protocol === "v2")
|
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||||
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
|
||||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||||
if (path === "/provider") return json(route, providerConfig(config))
|
if (path === "/provider") return json(route, providerConfig(config))
|
||||||
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
|
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
|
|||||||
export type SseConnectionRecord = {
|
export type SseConnectionRecord = {
|
||||||
id: number
|
id: number
|
||||||
url: string
|
url: string
|
||||||
path: "/global/event" | "/event" | "/api/event"
|
path: "/api/event"
|
||||||
headers: Record<string, string>
|
headers: Record<string, string>
|
||||||
openedAt: number
|
openedAt: number
|
||||||
endedAt?: number
|
endedAt?: number
|
||||||
@@ -174,10 +174,7 @@ export async function installSseTransport<T>(
|
|||||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const request = new Request(input, init)
|
const request = new Request(input, init)
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
if (
|
if (url.origin !== server || url.pathname !== "/api/event")
|
||||||
url.origin !== server ||
|
|
||||||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
|
|
||||||
)
|
|
||||||
return originalFetch(request)
|
return originalFetch(request)
|
||||||
|
|
||||||
const id = ++nextConnectionID
|
const id = ++nextConnectionID
|
||||||
@@ -193,18 +190,9 @@ export async function installSseTransport<T>(
|
|||||||
record.controller = controller
|
record.controller = controller
|
||||||
connections.push(record)
|
connections.push(record)
|
||||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
||||||
if (url.pathname === "/api/event")
|
controller.enqueue(
|
||||||
controller.enqueue(
|
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||||
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: {} },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
request.signal.addEventListener(
|
request.signal.addEventListener(
|
||||||
"abort",
|
"abort",
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -56,7 +56,6 @@
|
|||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/schema": "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/session-ui": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
|
|||||||
@@ -238,26 +238,6 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LayoutCompatibility(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}</>
|
return <>{props.children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
server: ServerConnection.key(serverSDK.server),
|
server: ServerConnection.key(serverSDK.server),
|
||||||
opened: serverCtx.projects.list,
|
opened: serverCtx.projects.list,
|
||||||
stored: () => serverCtx.sync.data.project,
|
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"),
|
untitled: () => language.t("command.session.new"),
|
||||||
category: () => language.t("command.category.session"),
|
category: () => language.t("command.category.session"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function DialogHomeCommandPaletteV2(props: {
|
|||||||
server: ServerConnection.key(props.server),
|
server: ServerConnection.key(props.server),
|
||||||
opened: serverCtx.projects.list,
|
opened: serverCtx.projects.list,
|
||||||
stored: () => serverCtx.sync.data.project,
|
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"),
|
untitled: () => language.t("command.session.new"),
|
||||||
category: () => language.t("command.category.session"),
|
category: () => language.t("command.category.session"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ function ProviderConnection(props: {
|
|||||||
() => ({ provider: props.provider, directory: directory() }),
|
() => ({ provider: props.provider, directory: directory() }),
|
||||||
(input) =>
|
(input) =>
|
||||||
serverSDK()
|
serverSDK()
|
||||||
.currentApi.integration.get({
|
.api.integration.get({
|
||||||
integrationID: input.provider,
|
integrationID: input.provider,
|
||||||
location: input.directory ? { directory: input.directory } : undefined,
|
location: input.directory ? { directory: input.directory } : undefined,
|
||||||
})
|
})
|
||||||
@@ -547,7 +547,7 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
dispatch({ type: "auth.pending" })
|
dispatch({ type: "auth.pending" })
|
||||||
await serverSDK()
|
await serverSDK()
|
||||||
.currentApi.integration.oauth.connect({
|
.api.integration.oauth.connect({
|
||||||
integrationID: props.provider,
|
integrationID: props.provider,
|
||||||
methodID: method.id,
|
methodID: method.id,
|
||||||
inputs: inputs ?? {},
|
inputs: inputs ?? {},
|
||||||
@@ -816,7 +816,7 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
await serverSDK().currentApi.integration.connect.key({
|
await serverSDK().api.integration.connect.key({
|
||||||
integrationID: props.provider,
|
integrationID: props.provider,
|
||||||
location: location(),
|
location: location(),
|
||||||
key: apiKey,
|
key: apiKey,
|
||||||
@@ -947,7 +947,7 @@ function ProviderConnection(props: {
|
|||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.currentApi.integration.oauth.complete({
|
.api.integration.oauth.complete({
|
||||||
integrationID: props.provider,
|
integrationID: props.provider,
|
||||||
attemptID: store.authorization!.attemptID,
|
attemptID: store.authorization!.attemptID,
|
||||||
location: location(),
|
location: location(),
|
||||||
@@ -1044,7 +1044,7 @@ function ProviderConnection(props: {
|
|||||||
const authorization = store.authorization
|
const authorization = store.authorization
|
||||||
if (!authorization || !alive.value) return
|
if (!authorization || !alive.value) return
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.currentApi.integration.oauth.status({
|
.api.integration.oauth.status({
|
||||||
integrationID: props.provider,
|
integrationID: props.provider,
|
||||||
attemptID: authorization.attemptID,
|
attemptID: authorization.attemptID,
|
||||||
location: location(),
|
location: location(),
|
||||||
|
|||||||
@@ -130,26 +130,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const saveMutation = useMutation(() => ({
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>): Promise<typeof result> => {
|
||||||
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
|
// TODO: Restore custom providers when V2 exposes config and arbitrary credential APIs.
|
||||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
throw new Error(`Custom provider ${result.providerID} is unavailable`)
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
|
||||||
|
|
||||||
if (result.key) {
|
|
||||||
await serverSDK().client.auth.set({
|
|
||||||
providerID: result.providerID,
|
|
||||||
auth: {
|
|
||||||
type: "api",
|
|
||||||
key: result.key,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await serverSync().updateConfig({
|
|
||||||
provider: { [result.providerID]: result.config },
|
|
||||||
disabled_providers: nextDisabled,
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
},
|
},
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
dialog.close()
|
dialog.close()
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export function DialogEditProjectV2(props: { project: LocalProject; server: Serv
|
|||||||
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
|
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
|
||||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
<Button type="button" variant="ghost" size="large" onClick={model.close}>
|
<Button type="button" variant="ghost" size="large" onClick={model.close}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
|
<Button type="submit" variant="primary" size="large" disabled={!model.supported || model.save.isPending}>
|
||||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export const DialogFork: Component = () => {
|
|||||||
const dir = base64Encode(sdk().directory)
|
const dir = base64Encode(sdk().directory)
|
||||||
|
|
||||||
sdk()
|
sdk()
|
||||||
.currentApi.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
|
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
|
||||||
.then((forked) => {
|
.then((forked) => {
|
||||||
dialog.close()
|
dialog.close()
|
||||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||||
|
|||||||
@@ -66,26 +66,21 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
let pathArea: HTMLDivElement | undefined
|
let pathArea: HTMLDivElement | undefined
|
||||||
let navigation = 0
|
let navigation = 0
|
||||||
|
|
||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
|
||||||
async (): Promise<Path | undefined> => {
|
() =>
|
||||||
if ((await sdk.protocol) === "v1")
|
sdk.api.location
|
||||||
return sdk.client.path
|
|
||||||
.get()
|
|
||||||
.then((result) => result.data)
|
|
||||||
.catch(() => undefined)
|
|
||||||
return sdk.currentApi.location
|
|
||||||
.get()
|
.get()
|
||||||
.then((location) => ({
|
.then(
|
||||||
state: "",
|
(location): Path => ({
|
||||||
config: "",
|
state: "",
|
||||||
worktree: location.project.directory,
|
config: "",
|
||||||
directory: location.directory,
|
worktree: location.project.directory,
|
||||||
home: "",
|
directory: location.directory,
|
||||||
}))
|
home: "",
|
||||||
.catch(() => undefined)
|
}),
|
||||||
},
|
)
|
||||||
|
.catch(() => undefined),
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
)
|
)
|
||||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||||
@@ -107,7 +102,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||||
const base = pickerRoot(cleaned) || root() || start()
|
const base = pickerRoot(cleaned) || root() || start()
|
||||||
if (!base) return { query: value, items: directories.slice(0, 5) }
|
if (!base) return { query: value, items: directories.slice(0, 5) }
|
||||||
const files = await sdk.currentApi.file
|
const files = await sdk.api.file
|
||||||
.find({
|
.find({
|
||||||
location: { directory: base },
|
location: { directory: base },
|
||||||
query: pickerFileSearchQuery(base, value, home()),
|
query: pickerFileSearchQuery(base, value, home()),
|
||||||
@@ -137,7 +132,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
existing ??
|
existing ??
|
||||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||||
return sdk.currentApi.file
|
return sdk.api.file
|
||||||
.list({ location: { directory: absolute } })
|
.list({ location: { directory: absolute } })
|
||||||
.then((result) =>
|
.then((result) =>
|
||||||
result.data.map((entry) => ({
|
result.data.map((entry) => ({
|
||||||
|
|||||||
@@ -57,26 +57,21 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const [filter, setFilter] = createSignal("")
|
const [filter, setFilter] = createSignal("")
|
||||||
let list: ListRef | undefined
|
let list: ListRef | undefined
|
||||||
|
|
||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
|
||||||
async (): Promise<Path | undefined> => {
|
() =>
|
||||||
if ((await sdk.protocol) === "v1")
|
sdk.api.location
|
||||||
return sdk.client.path
|
|
||||||
.get()
|
|
||||||
.then((result) => result.data)
|
|
||||||
.catch(() => undefined)
|
|
||||||
return sdk.currentApi.location
|
|
||||||
.get()
|
.get()
|
||||||
.then((location) => ({
|
.then(
|
||||||
state: "",
|
(location): Path => ({
|
||||||
config: "",
|
state: "",
|
||||||
worktree: location.project.directory,
|
config: "",
|
||||||
directory: location.directory,
|
worktree: location.project.directory,
|
||||||
home: "",
|
directory: location.directory,
|
||||||
}))
|
home: "",
|
||||||
.catch(() => undefined)
|
}),
|
||||||
},
|
)
|
||||||
|
.catch(() => undefined),
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { useGlobal } from "@/context/global"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { detectServerProtocol } from "@/utils/server-protocol"
|
|
||||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
@@ -264,13 +263,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
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()
|
resetAdd()
|
||||||
if (options.navigateOnAdd === false) {
|
if (options.navigateOnAdd === false) {
|
||||||
@@ -315,13 +307,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
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) {
|
if (normalized === input.original.http.url) {
|
||||||
server.add(conn)
|
server.add(conn)
|
||||||
} else {
|
} else {
|
||||||
@@ -360,9 +345,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
|
|
||||||
const sortedItems = createMemo(() => {
|
const sortedItems = createMemo(() => {
|
||||||
const raw = items()
|
const raw = items()
|
||||||
const list = settings.general.newLayoutDesigns()
|
const list = raw
|
||||||
? raw
|
|
||||||
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
|
||||||
if (!list.length) return list
|
if (!list.length) return list
|
||||||
const active = current()
|
const active = current()
|
||||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ test("scopes file autocomplete to the current browser root", () => {
|
|||||||
test("resolves directory autocomplete from the current browser root", async () => {
|
test("resolves directory autocomplete from the current browser root", async () => {
|
||||||
const directories: string[] = []
|
const directories: string[] = []
|
||||||
const sdk = {
|
const sdk = {
|
||||||
currentApi: {
|
api: {
|
||||||
file: {
|
file: {
|
||||||
find: (input: { location?: { directory?: string } }) => {
|
find: (input: { location?: { directory?: string } }) => {
|
||||||
directories.push(input.location?.directory ?? "")
|
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 () => {
|
test("searches from an absolute root without a default base", async () => {
|
||||||
const directories: string[] = []
|
const directories: string[] = []
|
||||||
const sdk = {
|
const sdk = {
|
||||||
currentApi: {
|
api: {
|
||||||
file: {
|
file: {
|
||||||
list: (input: { location?: { directory?: string } }) => {
|
list: (input: { location?: { directory?: string } }) => {
|
||||||
directories.push(input.location?.directory ?? "")
|
directories.push(input.location?.directory ?? "")
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
|||||||
const key = trimPickerPath(directory)
|
const key = trimPickerPath(directory)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const request = args.sdk.currentApi.file
|
const request = args.sdk.api.file
|
||||||
.list({ location: { directory: key } })
|
.list({ location: { directory: key } })
|
||||||
.then((result) => result.data)
|
.then((result) => result.data)
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
@@ -374,7 +374,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
|||||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||||
const query = normalizePickerDrive(input.path)
|
const query = normalizePickerDrive(input.path)
|
||||||
if (!pathInput) {
|
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 })
|
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
||||||
.then((result) => result.data.map((entry) => entry.path))
|
.then((result) => result.data.map((entry) => entry.path))
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { type LocalProject } from "@/context/layout"
|
|||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
|
|
||||||
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
|
const supported = !props.project.id || props.project.id === "global"
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||||
@@ -71,29 +72,9 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
|||||||
const start = store.startup.trim()
|
const start = store.startup.trim()
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
if (props.project.id && props.project.id !== "global") {
|
||||||
if ((await serverCtx().sdk.protocol) !== "v1") return
|
// TODO: Restore project edits when the V2 client exposes a project update API.
|
||||||
const project = await serverCtx()
|
// await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands })
|
||||||
.sdk.client.project.update({
|
throw new Error(`Project ${props.project.id} cannot be updated`)
|
||||||
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
|
|
||||||
// const project = await serverCtx().sdk.api.project.update({
|
|
||||||
// projectID: props.project.id,
|
|
||||||
// name,
|
|
||||||
// icon: { color: store.color || "", override: store.iconOverride || "" },
|
|
||||||
// commands: { start },
|
|
||||||
// })
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
serverCtx().sync.project.meta(props.project.worktree, {
|
serverCtx().sync.project.meta(props.project.worktree, {
|
||||||
@@ -107,7 +88,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
|||||||
|
|
||||||
function submit(event: SubmitEvent) {
|
function submit(event: SubmitEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (save.isPending) return
|
if (!supported || save.isPending) return
|
||||||
save.mutate()
|
save.mutate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +98,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
|||||||
folderName,
|
folderName,
|
||||||
defaultName,
|
defaultName,
|
||||||
save,
|
save,
|
||||||
|
supported,
|
||||||
submit,
|
submit,
|
||||||
drop,
|
drop,
|
||||||
dragOver,
|
dragOver,
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string
|
|||||||
const sentPrompts: string[] = []
|
const sentPrompts: string[] = []
|
||||||
const promptInputs: unknown[] = []
|
const promptInputs: unknown[] = []
|
||||||
const sentCommands: 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 }> = []
|
const commands: Array<{ name: string }> = []
|
||||||
let serverSessionSyncs = 0
|
let serverSessionSyncs = 0
|
||||||
|
|
||||||
@@ -93,10 +99,22 @@ const clientFor = (directory: string) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
prompt: async (input: unknown) => {
|
prompt: async (input: unknown) => {
|
||||||
|
sessionRequestOrder.push("prompt")
|
||||||
sentPrompts.push(directory)
|
sentPrompts.push(directory)
|
||||||
promptInputs.push(input)
|
promptInputs.push(input)
|
||||||
return { data: undefined }
|
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) => {
|
command: async (input: unknown) => {
|
||||||
sentCommands.push(input)
|
sentCommands.push(input)
|
||||||
},
|
},
|
||||||
@@ -125,13 +143,6 @@ beforeAll(async () => {
|
|||||||
useSearchParams: () => [search, () => undefined],
|
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", () => ({
|
mock.module("@opencode-ai/ui/toast", () => ({
|
||||||
Toast: { Region: () => null },
|
Toast: { Region: () => null },
|
||||||
showToast: () => 0,
|
showToast: () => 0,
|
||||||
@@ -197,13 +208,8 @@ beforeAll(async () => {
|
|||||||
const sdk = {
|
const sdk = {
|
||||||
scope: "local",
|
scope: "local",
|
||||||
directory: "/repo/main",
|
directory: "/repo/main",
|
||||||
client: rootClient,
|
|
||||||
api: rootClient.api,
|
api: rootClient.api,
|
||||||
currentApi: rootClient.api,
|
|
||||||
url: "http://localhost:4096",
|
url: "http://localhost:4096",
|
||||||
createClient(opts: any) {
|
|
||||||
return clientFor(opts.directory)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return () => sdk
|
return () => sdk
|
||||||
},
|
},
|
||||||
@@ -291,6 +297,9 @@ beforeEach(() => {
|
|||||||
sentPrompts.length = 0
|
sentPrompts.length = 0
|
||||||
promptInputs.length = 0
|
promptInputs.length = 0
|
||||||
sentCommands.length = 0
|
sentCommands.length = 0
|
||||||
|
switchedAgents.length = 0
|
||||||
|
switchedModels.length = 0
|
||||||
|
sessionRequestOrder.length = 0
|
||||||
commands.length = 0
|
commands.length = 0
|
||||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
params = {}
|
params = {}
|
||||||
@@ -448,13 +457,17 @@ describe("prompt submit worktree selection", () => {
|
|||||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
|
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" }
|
params = { id: "session-1" }
|
||||||
variant = "high"
|
variant = "high"
|
||||||
|
|
||||||
const submit = createPromptSubmit({
|
const submit = createPromptSubmit({
|
||||||
prompt,
|
prompt,
|
||||||
info: () => ({ id: "session-1" }),
|
info: () => ({
|
||||||
|
id: "session-1",
|
||||||
|
agent: "old-agent",
|
||||||
|
model: { id: "old-model", providerID: "old-provider" },
|
||||||
|
}),
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
autoAccept: () => false,
|
autoAccept: () => false,
|
||||||
@@ -483,6 +496,14 @@ describe("prompt submit worktree selection", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(sentPrompts).toEqual(["/repo/main"])
|
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({
|
expect(promptInputs[0]).toMatchObject({
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
text: "ls",
|
text: "ls",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
|
|||||||
import { useSync, type DirectorySync } from "@/context/sync"
|
import { useSync, type DirectorySync } from "@/context/sync"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
|
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
import { setCursorPosition } from "./editor-dom"
|
import { setCursorPosition } from "./editor-dom"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
@@ -22,7 +23,6 @@ import { ScopedKey } from "@/utils/server-scope"
|
|||||||
import { createPromptSubmissionState } from "./submission-state"
|
import { createPromptSubmissionState } from "./submission-state"
|
||||||
import { normalizeSessionInfo } from "@/utils/session"
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
@@ -42,9 +42,10 @@ export type FollowupDraft = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FollowupSendInput = {
|
type FollowupSendInput = {
|
||||||
api: DirectorySDK["currentApi"]["session"]
|
api: DirectorySDK["api"]["session"]
|
||||||
serverSync: ServerSync
|
serverSync: ServerSync
|
||||||
sync: DirectorySync
|
sync: DirectorySync
|
||||||
|
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
messageID?: string
|
messageID?: string
|
||||||
optimisticBusy?: boolean
|
optimisticBusy?: boolean
|
||||||
@@ -157,6 +158,25 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
return false
|
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({
|
await input.api.prompt({
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
id: messageID,
|
id: messageID,
|
||||||
@@ -197,7 +217,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
|
|
||||||
type PromptSubmitInput = {
|
type PromptSubmitInput = {
|
||||||
prompt: ReturnType<typeof usePrompt>
|
prompt: ReturnType<typeof usePrompt>
|
||||||
info: Accessor<{ id: string } | undefined>
|
info: Accessor<
|
||||||
|
{ id: string; agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined
|
||||||
|
>
|
||||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||||
commentCount: Accessor<number>
|
commentCount: Accessor<number>
|
||||||
autoAccept: Accessor<boolean>
|
autoAccept: Accessor<boolean>
|
||||||
@@ -261,7 +283,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
return sdk()
|
return sdk()
|
||||||
.currentApi.session.interrupt({ sessionID })
|
.api.session.interrupt({ sessionID })
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,11 +367,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||||
|
|
||||||
let sessionDirectory = projectDirectory
|
let sessionDirectory = projectDirectory
|
||||||
|
|
||||||
if (isNewSession) {
|
if (isNewSession) {
|
||||||
if (worktreeSelection === "create") {
|
if (worktreeSelection === "create") {
|
||||||
const createdWorktree = await sdk()
|
const createdWorktree = await sdk()
|
||||||
.currentApi.projectCopy.create({
|
.api.projectCopy.create({
|
||||||
projectID: sync().data.project,
|
projectID: sync().data.project,
|
||||||
strategy: "git_worktree",
|
strategy: "git_worktree",
|
||||||
directory: getDirectory(projectDirectory),
|
directory: getDirectory(projectDirectory),
|
||||||
@@ -362,7 +383,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
})
|
})
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!createdWorktree) return
|
if (!createdWorktree) return
|
||||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
||||||
sessionDirectory = createdWorktree.directory
|
sessionDirectory = createdWorktree.directory
|
||||||
@@ -382,7 +402,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
let session = input.info()
|
let session = input.info()
|
||||||
if (!session && isNewSession) {
|
if (!session && isNewSession) {
|
||||||
const created = await sdk()
|
const created = await sdk()
|
||||||
.currentApi.session.create({
|
.api.session.create({
|
||||||
agent: currentAgent.name,
|
agent: currentAgent.name,
|
||||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||||
location: { directory: sessionDirectory },
|
location: { directory: sessionDirectory },
|
||||||
@@ -473,7 +493,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
clearInput()
|
clearInput()
|
||||||
const eventID = Event.ID.create()
|
const eventID = Event.ID.create()
|
||||||
sdk()
|
sdk()
|
||||||
.currentApi.session.shell({
|
.api.session.shell({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
id: eventID,
|
id: eventID,
|
||||||
command: text,
|
command: text,
|
||||||
@@ -497,7 +517,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const messageID = Identifier.ascending("message")
|
const messageID = Identifier.ascending("message")
|
||||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||||
sdk()
|
sdk()
|
||||||
.currentApi.session.command({
|
.api.session.command({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
id: messageID,
|
id: messageID,
|
||||||
command: commandName,
|
command: commandName,
|
||||||
@@ -594,9 +614,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void sendFollowupDraft({
|
void sendFollowupDraft({
|
||||||
api: sdk().currentApi.session,
|
api: sdk().api.session,
|
||||||
sync: sync(),
|
sync: sync(),
|
||||||
serverSync: serverSync(),
|
serverSync: serverSync(),
|
||||||
|
session: () => input.info() ?? session,
|
||||||
draft,
|
draft,
|
||||||
messageID,
|
messageID,
|
||||||
optimisticBusy: sessionDirectory === projectDirectory,
|
optimisticBusy: sessionDirectory === projectDirectory,
|
||||||
|
|||||||
@@ -128,11 +128,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
async () => {
|
async () => {
|
||||||
const sdk = serverSdk()
|
// TODO: Restore executable shell discovery; V2 shell.list only lists shell processes.
|
||||||
if ((await sdk.protocol) === "v1") {
|
|
||||||
return (await sdk.client.pty.shells()).data ?? []
|
|
||||||
}
|
|
||||||
// return (await sdk.api.pty.shells()).data
|
|
||||||
return [] as ShellOption[]
|
return [] as ShellOption[]
|
||||||
},
|
},
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
@@ -331,6 +327,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
data-action="settings-shell"
|
data-action="settings-shell"
|
||||||
|
disabled
|
||||||
options={shellOptions()}
|
options={shellOptions()}
|
||||||
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
||||||
value={(o) => o.id}
|
value={(o) => o.id}
|
||||||
@@ -338,7 +335,8 @@ export const SettingsGeneral: Component = () => {
|
|||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
if (option.value === currentShell()) return
|
if (option.value === currentShell()) return
|
||||||
serverSync().updateConfig({ shell: option.value })
|
// TODO: Restore config writes when the V2 client exposes a config API.
|
||||||
|
// void serverSync().updateConfig({ shell: option.value })
|
||||||
}}
|
}}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
|
|||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { createMemo, type Component, For, Show } from "solid-js"
|
import { createMemo, type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
||||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||||
@@ -39,7 +39,6 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const protocol = useServerProtocol()
|
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const providers = useProviders(() => undefined)
|
const providers = useProviders(() => undefined)
|
||||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||||
@@ -84,8 +83,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
|||||||
return language.t("settings.providers.tag.other")
|
return language.t("settings.providers.tag.other")
|
||||||
}
|
}
|
||||||
|
|
||||||
const canDisconnect = (item: ProviderItem) =>
|
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
|
||||||
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
|
|
||||||
|
|
||||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||||
|
|
||||||
@@ -98,7 +96,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disableProvider = async (providerID: string, name: string) => {
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
if (protocol() !== "v1") return
|
return
|
||||||
const before = serverSync().data.config.disabled_providers ?? []
|
const before = serverSync().data.config.disabled_providers ?? []
|
||||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
serverSync().set("config", "disabled_providers", next)
|
serverSync().set("config", "disabled_providers", next)
|
||||||
@@ -121,20 +119,13 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
const disconnect = async (providerID: string, name: string) => {
|
||||||
if (isConfigCustom(providerID)) {
|
|
||||||
await serverSDK()
|
|
||||||
.client.auth.remove({ providerID })
|
|
||||||
.catch(() => undefined)
|
|
||||||
await disableProvider(providerID, name)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await serverSDK()
|
await serverSDK()
|
||||||
.currentApi.integration.get({ integrationID: providerID })
|
.api.integration.get({ integrationID: providerID })
|
||||||
.then(async (integration) => {
|
.then(async (integration) => {
|
||||||
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
||||||
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
credentials.map((credential) => serverSDK().currentApi.credential.remove({ credentialID: credential.id })),
|
credentials.map((credential) => serverSDK().api.credential.remove({ credentialID: credential.id })),
|
||||||
)
|
)
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
@@ -225,7 +216,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<Show when={protocol() === "v1"}>
|
<Show when={false}>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
|
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
|
||||||
data-component="custom-provider-section"
|
data-component="custom-provider-section"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useUpdaterAction } from "../updater-action"
|
import { useUpdaterAction } from "../updater-action"
|
||||||
import {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
@@ -91,7 +90,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSdk = useServerSDK()
|
|
||||||
const mobile = createMediaQuery("(max-width: 767px)")
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
|
|
||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
@@ -123,10 +121,7 @@ export const SettingsGeneralV2: Component<{
|
|||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
async () => {
|
async () => {
|
||||||
const sdk = serverSdk()
|
// TODO: Restore executable shell discovery when the V2 client exposes it.
|
||||||
if ((await sdk.protocol) === "v1") {
|
|
||||||
return (await sdk.client.pty.shells()).data ?? []
|
|
||||||
}
|
|
||||||
// return (await sdk.api.pty.shells()).data
|
// return (await sdk.api.pty.shells()).data
|
||||||
return [] as ShellOption[]
|
return [] as ShellOption[]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
|
|||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
|
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||||
@@ -36,7 +36,6 @@ export const SettingsProvidersV2: Component<{
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
const protocol = useServerProtocol()
|
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const providers = useProviders(props.directory)
|
const providers = useProviders(props.directory)
|
||||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||||
@@ -81,8 +80,7 @@ export const SettingsProvidersV2: Component<{
|
|||||||
return language.t("settings.providers.tag.other")
|
return language.t("settings.providers.tag.other")
|
||||||
}
|
}
|
||||||
|
|
||||||
const canDisconnect = (item: ProviderItem) =>
|
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
|
||||||
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
|
|
||||||
|
|
||||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||||
|
|
||||||
@@ -95,7 +93,7 @@ export const SettingsProvidersV2: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disableProvider = async (providerID: string, name: string) => {
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
if (protocol() !== "v1") return
|
return
|
||||||
const before = serverSync().data.config.disabled_providers ?? []
|
const before = serverSync().data.config.disabled_providers ?? []
|
||||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
serverSync().set("config", "disabled_providers", next)
|
serverSync().set("config", "disabled_providers", next)
|
||||||
@@ -118,22 +116,15 @@ export const SettingsProvidersV2: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
const disconnect = async (providerID: string, name: string) => {
|
||||||
if (isConfigCustom(providerID)) {
|
|
||||||
await serverSdk()
|
|
||||||
.client.auth.remove({ providerID })
|
|
||||||
.catch(() => undefined)
|
|
||||||
await disableProvider(providerID, name)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const location = props.directory() ? { directory: props.directory() } : undefined
|
const location = props.directory() ? { directory: props.directory() } : undefined
|
||||||
await serverSdk()
|
await serverSdk()
|
||||||
.currentApi.integration.get({ integrationID: providerID, location })
|
.api.integration.get({ integrationID: providerID, location })
|
||||||
.then(async (integration) => {
|
.then(async (integration) => {
|
||||||
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
||||||
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
credentials.map((credential) =>
|
credentials.map((credential) =>
|
||||||
serverSdk().currentApi.credential.remove({ credentialID: credential.id, location }),
|
serverSdk().api.credential.remove({ credentialID: credential.id, location }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
showToast({
|
showToast({
|
||||||
@@ -231,7 +222,7 @@ export const SettingsProvidersV2: Component<{
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<Show when={protocol() === "v1"}>
|
<Show when={false}>
|
||||||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||||
<div class="settings-v2-provider-lead">
|
<div class="settings-v2-provider-lead">
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Switch } from "@opencode-ai/ui/switch"
|
|||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
import { type Accessor, createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -16,7 +16,7 @@ import { type ServerHealth } from "@/utils/server-health"
|
|||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useMcpToggle } from "@/context/mcp"
|
import { useMcpToggle } from "@/context/mcp"
|
||||||
import { useServerProtocol } from "@/context/server-sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
|
||||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||||
const parts = value.split(file)
|
const parts = value.split(file)
|
||||||
@@ -251,6 +251,7 @@ function ServerStatusList(props: { state: ServerStatusState }) {
|
|||||||
|
|
||||||
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const sdk = useSDK()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
@@ -258,7 +259,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const protocol = useServerProtocol()
|
|
||||||
|
|
||||||
const fail = (err: unknown) => {
|
const fail = (err: unknown) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -279,9 +279,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
dialogRun += 1
|
dialogRun += 1
|
||||||
})
|
})
|
||||||
const sortedServers = createMemo(() => {
|
const sortedServers = createMemo(() => {
|
||||||
const list = settings.general.newLayoutDesigns()
|
const list = global.servers.list()
|
||||||
? global.servers.list()
|
|
||||||
: global.servers.list().filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
|
||||||
return listServersByHealth(list, server.key, global.servers.health)
|
return listServersByHealth(list, server.key, global.servers.health)
|
||||||
})
|
})
|
||||||
const toggleMcp = useMcpToggle()
|
const toggleMcp = useMcpToggle()
|
||||||
@@ -291,9 +289,11 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
||||||
const lspCount = createMemo(() => lspItems().length)
|
const lspCount = createMemo(() => lspItems().length)
|
||||||
const plugins = createMemo(() =>
|
const [pluginList] = createResource(
|
||||||
(sync().data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
|
() => (props.shown() ? sdk().directory : undefined),
|
||||||
|
(directory) => sdk().api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||||
)
|
)
|
||||||
|
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
|
||||||
const pluginCount = createMemo(() => plugins().length)
|
const pluginCount = createMemo(() => plugins().length)
|
||||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||||
|
|
||||||
@@ -322,7 +322,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||||
{language.t("status.popover.tab.lsp")}
|
{language.t("status.popover.tab.lsp")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Show when={protocol() === "v1"}>
|
<Show when={true}>
|
||||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||||
{language.t("status.popover.tab.plugins")}
|
{language.t("status.popover.tab.plugins")}
|
||||||
@@ -487,7 +487,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
<Show when={protocol() === "v1"}>
|
<Show when={true}>
|
||||||
<Tabs.Content value="plugins">
|
<Tabs.Content value="plugins">
|
||||||
<div class="flex flex-col px-2 pb-2">
|
<div class="flex flex-col px-2 pb-2">
|
||||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||||
|
|||||||
@@ -11,13 +11,11 @@ import { matchKeybind, parseKeybind } from "@/context/command"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { terminalFontFamily, useSettings } from "@/context/settings"
|
import { terminalFontFamily, useSettings } from "@/context/settings"
|
||||||
import type { LocalPTY } from "@/context/terminal"
|
import type { LocalPTY } from "@/context/terminal"
|
||||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||||
import { terminalWriter } from "@/utils/terminal-writer"
|
import { terminalWriter } from "@/utils/terminal-writer"
|
||||||
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
|
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
|
||||||
import { authTokenFromCredentials } from "@/utils/server"
|
|
||||||
|
|
||||||
const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
||||||
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||||
@@ -176,13 +174,8 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
|
|
||||||
const connection = useServerSDK()().server
|
|
||||||
const directory = sdk().directory
|
const directory = sdk().directory
|
||||||
const url = sdk().url
|
const url = sdk().url
|
||||||
const auth = connection.http
|
|
||||||
const username = auth?.username ?? "opencode"
|
|
||||||
const password = auth?.password ?? ""
|
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, [
|
const [local, others] = splitProps(props, [
|
||||||
"pty",
|
"pty",
|
||||||
@@ -241,7 +234,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
const pushSize = async (cols: number, rows: number) => {
|
const pushSize = async (cols: number, rows: number) => {
|
||||||
return sdk()
|
return sdk()
|
||||||
.currentApi.pty.update({
|
.api.pty.update({
|
||||||
ptyID: id,
|
ptyID: id,
|
||||||
location: { directory },
|
location: { directory },
|
||||||
size: { cols, rows },
|
size: { cols, rows },
|
||||||
@@ -523,7 +516,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
const gone = async () => {
|
const gone = async () => {
|
||||||
return sdk()
|
return sdk()
|
||||||
.currentApi.pty.get({ ptyID: id, location: { directory } })
|
.api.pty.get({ ptyID: id, location: { directory } })
|
||||||
.then((result) => result.data.status === "exited")
|
.then((result) => result.data.status === "exited")
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
|
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
|
||||||
@@ -533,23 +526,8 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const connectToken = async () => {
|
const connectToken = async () => {
|
||||||
const endpoint = new URL(`/api/pty/${encodeURIComponent(id)}/connect-token`, url)
|
// TODO: Add PTY tickets when the V2 client exposes a connect-token API.
|
||||||
endpoint.searchParams.set("location[directory]", directory)
|
return undefined
|
||||||
const response = await (platform.fetch ?? globalThis.fetch)(endpoint, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"x-opencode-ticket": "1",
|
|
||||||
...(password
|
|
||||||
? { Authorization: `Basic ${authTokenFromCredentials({ username, password })}` }
|
|
||||||
: undefined),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (response.status === 403)
|
|
||||||
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
|
||||||
if (!response.ok) throw new Error(`PTY connect ticket failed with ${response.status}`)
|
|
||||||
const result = (await response.json()) as { data?: { ticket?: string } }
|
|
||||||
if (!result.data?.ticket) throw new Error("PTY connect ticket response did not include a ticket")
|
|
||||||
return result.data.ticket
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const retry = (err: unknown) => {
|
const retry = (err: unknown) => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
|
|||||||
import { createMutation } from "@tanstack/solid-query"
|
import { createMutation } from "@tanstack/solid-query"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { ServerConnection, serverName } from "@/context/server"
|
import { ServerConnection, serverName } from "@/context/server"
|
||||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||||
@@ -55,10 +54,7 @@ export function TabNavItem(props: {
|
|||||||
if (!session) return
|
if (!session) return
|
||||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||||
})
|
})
|
||||||
const title = createMemo(() => {
|
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
|
||||||
const session = props.session()
|
|
||||||
return session ? displayLabel(session) : props.fallbackTitle
|
|
||||||
})
|
|
||||||
|
|
||||||
const projectName = createMemo(() => {
|
const projectName = createMemo(() => {
|
||||||
const session = props.session()
|
const session = props.session()
|
||||||
@@ -306,7 +302,7 @@ export function TabNavItem(props: {
|
|||||||
}}
|
}}
|
||||||
data={{
|
data={{
|
||||||
projectName: projectName(),
|
projectName: projectName(),
|
||||||
title: title(),
|
title: props.session()?.title,
|
||||||
path: previewPath(),
|
path: previewPath(),
|
||||||
serverName: serverLabel(),
|
serverName: serverLabel(),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ function SessionTabEntry(props: {
|
|||||||
|
|
||||||
ctx.sync.session.remember({ ...value, title })
|
ctx.sync.session.remember({ ...value, title })
|
||||||
try {
|
try {
|
||||||
await ctx.sdk.currentApi.session.rename({ sessionID: value.id, title })
|
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const current = session()
|
const current = session()
|
||||||
const currentCtx = props.serverCtx()
|
const currentCtx = props.serverCtx()
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
||||||
},
|
},
|
||||||
({ route, sdk }) =>
|
({ route, sdk }) =>
|
||||||
sdk.currentApi.session
|
sdk.api.session
|
||||||
.get({ sessionID: route.sessionId })
|
.get({ sessionID: route.sessionId })
|
||||||
.then(normalizeSessionInfo)
|
.then(normalizeSessionInfo)
|
||||||
.catch(() => {}),
|
.catch(() => {}),
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export const createDirSyncContext = (
|
|||||||
serverSync: ReturnType<typeof createServerSyncContextInner>,
|
serverSync: ReturnType<typeof createServerSyncContextInner>,
|
||||||
serverSDK: ReturnType<typeof createServerSdkContext>,
|
serverSDK: ReturnType<typeof createServerSdkContext>,
|
||||||
) => {
|
) => {
|
||||||
const client = serverSDK.createClient({ directory, throwOnError: true })
|
|
||||||
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
|
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
|
||||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||||
const data = new Proxy({} as State, {
|
const data = new Proxy({} as State, {
|
||||||
@@ -124,7 +123,7 @@ export const createDirSyncContext = (
|
|||||||
fetch: async (count = 10) => {
|
fetch: async (count = 10) => {
|
||||||
const [store, setStore] = current()
|
const [store, setStore] = current()
|
||||||
setStore("limit", (value) => value + count)
|
setStore("limit", (value) => value + count)
|
||||||
const response = await serverSDK.currentApi.session.list({ directory, limit: store.limit, order: "desc" })
|
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
|
||||||
const sessions = response.data
|
const sessions = response.data
|
||||||
.map(normalizeSessionInfo)
|
.map(normalizeSessionInfo)
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
@@ -134,15 +133,8 @@ export const createDirSyncContext = (
|
|||||||
},
|
},
|
||||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||||
archive: async (sessionID: string) => {
|
archive: async (sessionID: string) => {
|
||||||
if ((await serverSDK.protocol) !== "v1") return
|
// TODO: Restore archiving when the V2 client exposes a session archive API.
|
||||||
await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } })
|
void sessionID
|
||||||
current()[1](
|
|
||||||
"session",
|
|
||||||
produce((draft) => {
|
|
||||||
const match = Binary.search(draft, sessionID, (session) => session.id)
|
|
||||||
if (match.found) draft.splice(match.index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
normalizeDir: path.normalizeDir,
|
normalizeDir: path.normalizeDir,
|
||||||
list: (dir) =>
|
list: (dir) =>
|
||||||
sdk()
|
sdk()
|
||||||
.currentApi.file.list({ path: dir, location: { directory: scope() } })
|
.api.file.list({ path: dir, location: { directory: scope() } })
|
||||||
.then((x) =>
|
.then((x) =>
|
||||||
x.data.map((entry) => ({
|
x.data.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
@@ -188,7 +188,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
setLoading(file)
|
setLoading(file)
|
||||||
|
|
||||||
const promise = sdk()
|
const promise = sdk()
|
||||||
.currentApi.file.read({ path: file, location: { directory } })
|
.api.file.read({ path: file, location: { directory } })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (scope() !== directory) return
|
if (scope() !== directory) return
|
||||||
const content = { type: "text" as const, content: new TextDecoder().decode(data) }
|
const content = { type: "text" as const, content: new TextDecoder().decode(data) }
|
||||||
@@ -212,7 +212,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
||||||
serverSDK()
|
serverSDK()
|
||||||
.currentApi.file.find(
|
.api.file.find(
|
||||||
{
|
{
|
||||||
location: { directory: sdk().directory },
|
location: { directory: sdk().directory },
|
||||||
query,
|
query,
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import type { Config, Project } from "@/types"
|
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
|
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
|
||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import {
|
import {
|
||||||
bootstrapDirectory,
|
|
||||||
loadAgentsQuery,
|
loadAgentsQuery,
|
||||||
loadCommands,
|
loadCommands,
|
||||||
loadPathQuery,
|
loadPathQuery,
|
||||||
@@ -14,169 +10,19 @@ import {
|
|||||||
loadProvidersQuery,
|
loadProvidersQuery,
|
||||||
loadReferencesQuery,
|
loadReferencesQuery,
|
||||||
} from "./bootstrap"
|
} from "./bootstrap"
|
||||||
import type { State, VcsCache } from "./types"
|
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
type ProjectApi = ServerApi["project"]
|
type ProjectApi = ServerApi["project"]
|
||||||
|
|
||||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
|
||||||
const api = {
|
|
||||||
agent: { list: async () => ({ location: {}, data: [] }) },
|
|
||||||
provider: { list: async () => ({ location: {}, data: [] }) },
|
|
||||||
model: {
|
|
||||||
list: async () => ({ location: {}, data: [] }),
|
|
||||||
default: async () => ({ location: {}, data: null }),
|
|
||||||
},
|
|
||||||
permission: { request: { list: async () => ({ location: {}, data: [] }) } },
|
|
||||||
project: {
|
|
||||||
list: async () => [],
|
|
||||||
current: async () => ({ id: "project", directory: "/project" }),
|
|
||||||
},
|
|
||||||
question: { request: { list: async () => ({ location: {}, data: [] }) } },
|
|
||||||
reference: { list: async () => ({ location: {}, data: [] }) },
|
|
||||||
vcs: { get: async () => ({ location: {}, data: {} }) },
|
|
||||||
} as unknown as ServerApi
|
|
||||||
|
|
||||||
function directoryState() {
|
|
||||||
return createStore<State>({
|
|
||||||
status: "loading",
|
|
||||||
agent: [],
|
|
||||||
command: [],
|
|
||||||
reference: [],
|
|
||||||
project: "",
|
|
||||||
projectMeta: undefined,
|
|
||||||
icon: undefined,
|
|
||||||
provider_ready: true,
|
|
||||||
provider,
|
|
||||||
config: {},
|
|
||||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
|
||||||
session: [],
|
|
||||||
sessionTotal: 0,
|
|
||||||
session_status: {},
|
|
||||||
session_working(id: string) {
|
|
||||||
return this.session_status[id]?.type !== "idle"
|
|
||||||
},
|
|
||||||
session_diff: {},
|
|
||||||
todo: {},
|
|
||||||
permission: {},
|
|
||||||
question: {},
|
|
||||||
mcp_ready: true,
|
|
||||||
mcp: {},
|
|
||||||
mcp_resource: {},
|
|
||||||
lsp_ready: true,
|
|
||||||
lsp: [],
|
|
||||||
vcs: undefined,
|
|
||||||
limit: 5,
|
|
||||||
message: {},
|
|
||||||
session_message: {},
|
|
||||||
part: {},
|
|
||||||
part_text_accum_delta: {},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("bootstrapDirectory", () => {
|
|
||||||
test("uses current MCP endpoints while retaining unsupported v1 directory reads", async () => {
|
|
||||||
const mcpReads: string[] = []
|
|
||||||
const [store, setStore] = directoryState()
|
|
||||||
const currentApi = {
|
|
||||||
...api,
|
|
||||||
command: {
|
|
||||||
list: async () => {
|
|
||||||
mcpReads.push("command")
|
|
||||||
return { location: {}, data: [] }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mcp: {
|
|
||||||
list: async () => {
|
|
||||||
mcpReads.push("status")
|
|
||||||
return { location: {}, data: [] }
|
|
||||||
},
|
|
||||||
resource: {
|
|
||||||
catalog: async () => {
|
|
||||||
mcpReads.push("resource")
|
|
||||||
return { location: {}, data: { resources: [], templates: [] } }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as ServerApi
|
|
||||||
|
|
||||||
await bootstrapDirectory({
|
|
||||||
directory: "/project",
|
|
||||||
scope: ServerScope.local,
|
|
||||||
mcp: true,
|
|
||||||
global: {
|
|
||||||
config: {} satisfies Config,
|
|
||||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
|
||||||
project: [{ id: "project", worktree: "/project" } as Project],
|
|
||||||
provider,
|
|
||||||
},
|
|
||||||
sdk: {
|
|
||||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
|
||||||
config: { get: async () => ({ data: {} }) },
|
|
||||||
session: { status: async () => ({ data: {} }) },
|
|
||||||
vcs: { get: async () => ({ data: undefined }) },
|
|
||||||
command: {
|
|
||||||
list: async () => {
|
|
||||||
mcpReads.push("command")
|
|
||||||
return { data: [] }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
permission: { list: async () => ({ data: [] }) },
|
|
||||||
question: { list: async () => ({ data: [] }) },
|
|
||||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
|
||||||
mcp: {
|
|
||||||
status: async () => {
|
|
||||||
mcpReads.push("status")
|
|
||||||
return { data: {} }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
experimental: {
|
|
||||||
resource: {
|
|
||||||
list: async () => {
|
|
||||||
mcpReads.push("resource")
|
|
||||||
return { data: {} }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
|
||||||
} as unknown as OpencodeClient,
|
|
||||||
api: currentApi,
|
|
||||||
store,
|
|
||||||
setStore,
|
|
||||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
|
||||||
loadSessions() {},
|
|
||||||
translate: (key) => key,
|
|
||||||
queryClient: new QueryClient(),
|
|
||||||
protocol: Promise.resolve("v1"),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(store.status).toBe("partial")
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
|
||||||
|
|
||||||
expect(store.status).toBe("complete")
|
|
||||||
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("query keys", () => {
|
describe("query keys", () => {
|
||||||
test("partitions identical directories by server scope", () => {
|
test("partitions identical directories by server scope", () => {
|
||||||
const location = {} as Parameters<typeof loadPathQuery>[2]
|
|
||||||
const client = {} as Parameters<typeof loadPathQuery>[3]
|
|
||||||
const api = {} as CatalogApi
|
const api = {} as CatalogApi
|
||||||
|
const location = {} as ServerApi["location"]
|
||||||
const remote = "https://debian.example" as typeof ServerScope.local
|
const remote = "https://debian.example" as typeof ServerScope.local
|
||||||
|
|
||||||
expect([...loadPathQuery(ServerScope.local, "/repo", location, client).queryKey]).toEqual([
|
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["local", "/repo", "path"])
|
||||||
"local",
|
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||||
"/repo",
|
|
||||||
"path",
|
|
||||||
])
|
|
||||||
expect([...loadPathQuery(remote, "/repo", location, client).queryKey]).toEqual([
|
|
||||||
"https://debian.example",
|
|
||||||
"/repo",
|
|
||||||
"path",
|
|
||||||
])
|
|
||||||
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
|
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -211,6 +57,21 @@ describe("query keys", () => {
|
|||||||
expect(result.connected).toEqual(["openai"])
|
expect(result.connected).toEqual(["openai"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("loads current location metadata", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const api = {
|
||||||
|
get: async (input: unknown) => {
|
||||||
|
calls.push(input)
|
||||||
|
return { directory: "/repo/subpath", project: { id: "project", directory: "/repo" } }
|
||||||
|
},
|
||||||
|
} as ServerApi["location"]
|
||||||
|
|
||||||
|
const result = await new QueryClient().fetchQuery(loadPathQuery(ServerScope.local, "/repo/subpath", api))
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ location: { directory: "/repo/subpath" } }])
|
||||||
|
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
|
||||||
|
})
|
||||||
|
|
||||||
test("loads agents from the current location-scoped endpoint", async () => {
|
test("loads agents from the current location-scoped endpoint", async () => {
|
||||||
const calls: unknown[] = []
|
const calls: unknown[] = []
|
||||||
const api = {
|
const api = {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type {
|
|||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
Session,
|
Session,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import type {
|
import type {
|
||||||
AgentListInput,
|
AgentListInput,
|
||||||
AgentListOutput,
|
AgentListOutput,
|
||||||
@@ -45,7 +44,6 @@ import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
|||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
import { normalizeSessionInfo } from "@/utils/session"
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
import type { ServerProtocol } from "@/utils/server-protocol"
|
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
@@ -107,10 +105,11 @@ function showErrors(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
export const loadGlobalConfigQuery = (scope: ServerScope) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, "config"],
|
queryKey: [scope, "config"],
|
||||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
// TODO: Restore config loading when the V2 client exposes a config API.
|
||||||
|
queryFn: async (): Promise<Config> => ({}),
|
||||||
})
|
})
|
||||||
|
|
||||||
type ProjectApi = {
|
type ProjectApi = {
|
||||||
@@ -141,9 +140,7 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
serverSDK: OpencodeClient
|
|
||||||
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
|
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
|
||||||
protocol?: Promise<ServerProtocol>
|
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
requestFailedTitle: string
|
requestFailedTitle: string
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
@@ -152,15 +149,12 @@ export async function bootstrapGlobal(input: {
|
|||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
}) {
|
}) {
|
||||||
const slow = [
|
const slow = [
|
||||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient.fetchQuery(
|
input.queryClient.fetchQuery(
|
||||||
loadProvidersQuery(input.scope, null, input.serverAPI),
|
loadProvidersQuery(input.scope, null, input.serverAPI),
|
||||||
),
|
),
|
||||||
() =>
|
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
||||||
input.queryClient.fetchQuery(
|
|
||||||
loadPathQuery(input.scope, null, input.serverAPI.location, input.serverSDK, input.protocol),
|
|
||||||
),
|
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||||
@@ -273,22 +267,17 @@ export const loadPathQuery = (
|
|||||||
scope: ServerScope,
|
scope: ServerScope,
|
||||||
directory: string | null,
|
directory: string | null,
|
||||||
api: LocationApi,
|
api: LocationApi,
|
||||||
sdk: OpencodeClient,
|
|
||||||
protocol?: Promise<ServerProtocol>,
|
|
||||||
) =>
|
) =>
|
||||||
queryOptions<Path>({
|
queryOptions<Path>({
|
||||||
queryKey: [scope, directory, "path"],
|
queryKey: [scope, directory, "path"],
|
||||||
queryFn: async () => {
|
queryFn: () =>
|
||||||
if ((await protocol) === "v1")
|
api.get(directory ? { location: { directory } } : undefined).then((location) => ({
|
||||||
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
|
|
||||||
return retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({
|
|
||||||
state: "",
|
state: "",
|
||||||
config: "",
|
config: "",
|
||||||
worktree: location.project.directory,
|
worktree: location.project.directory,
|
||||||
directory: location.directory,
|
directory: location.directory,
|
||||||
home: "",
|
home: "",
|
||||||
}))
|
})),
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadReferencesQuery = (
|
export const loadReferencesQuery = (
|
||||||
@@ -307,7 +296,6 @@ export async function bootstrapDirectory(input: {
|
|||||||
directory: string
|
directory: string
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
mcp: boolean
|
mcp: boolean
|
||||||
sdk: OpencodeClient
|
|
||||||
api: CatalogApi & {
|
api: CatalogApi & {
|
||||||
readonly agent: AgentListApi
|
readonly agent: AgentListApi
|
||||||
readonly command: CommandListApi
|
readonly command: CommandListApi
|
||||||
@@ -333,7 +321,6 @@ export async function bootstrapDirectory(input: {
|
|||||||
}
|
}
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
session?: ServerSession
|
session?: ServerSession
|
||||||
protocol?: Promise<ServerProtocol>
|
|
||||||
}) {
|
}) {
|
||||||
const loading = input.store.status !== "complete"
|
const loading = input.store.status !== "complete"
|
||||||
const seededProject = projectID(input.directory, input.global.project)
|
const seededProject = projectID(input.directory, input.global.project)
|
||||||
@@ -355,35 +342,6 @@ export async function bootstrapDirectory(input: {
|
|||||||
input.queryClient
|
input.queryClient
|
||||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
|
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
|
||||||
.then((data) => input.setStore("agent", data)),
|
.then((data) => input.setStore("agent", data)),
|
||||||
() =>
|
|
||||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
|
||||||
() =>
|
|
||||||
retry(() =>
|
|
||||||
(async () => {
|
|
||||||
if ((await input.protocol) !== "v1") return
|
|
||||||
const x = await input.sdk.session.status()
|
|
||||||
if (!input.session) {
|
|
||||||
input.setStore("session_status", x.data!)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const statuses = x.data ?? {}
|
|
||||||
input.session.set(
|
|
||||||
"session_status",
|
|
||||||
produce((draft) => {
|
|
||||||
for (const sessionID of Object.keys(draft)) {
|
|
||||||
if (statuses[sessionID]) continue
|
|
||||||
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
|
||||||
input.session.set("session_status", sessionID, reconcile(status))
|
|
||||||
}
|
|
||||||
await Promise.all(
|
|
||||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
|
||||||
)
|
|
||||||
})(),
|
|
||||||
),
|
|
||||||
!seededProject &&
|
!seededProject &&
|
||||||
(() =>
|
(() =>
|
||||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||||
@@ -392,22 +350,11 @@ export async function bootstrapDirectory(input: {
|
|||||||
!seededPath &&
|
!seededPath &&
|
||||||
(() =>
|
(() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.ensureQueryData(
|
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location))
|
||||||
loadPathQuery(input.scope, input.directory, input.api.location, input.sdk, input.protocol),
|
|
||||||
)
|
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||||
if (next) input.setStore("project", next)
|
if (next) input.setStore("project", next)
|
||||||
})),
|
})),
|
||||||
() =>
|
|
||||||
retry(async () => {
|
|
||||||
if ((await input.protocol) !== "v1") return
|
|
||||||
return input.sdk.vcs.get().then((result) => {
|
|
||||||
const next = { branch: result.data?.branch, default_branch: result.data?.default_branch }
|
|
||||||
input.setStore("vcs", next)
|
|
||||||
if (next) input.vcsCache.setStore("value", next)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
input.mcp &&
|
input.mcp &&
|
||||||
(() =>
|
(() =>
|
||||||
loadCommands(input.directory, input.api.command).then((commands) =>
|
loadCommands(input.directory, input.api.command).then((commands) =>
|
||||||
@@ -419,12 +366,10 @@ export async function bootstrapDirectory(input: {
|
|||||||
),
|
),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
(async () => {
|
input.api.permission.request
|
||||||
if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
|
.list({ location: { directory: input.directory } })
|
||||||
return input.api.permission.request
|
.then((result) => result.data.map(normalizePermissionRequest))
|
||||||
.list({ location: { directory: input.directory } })
|
.then((permissions) => {
|
||||||
.then((result) => result.data.map(normalizePermissionRequest))
|
|
||||||
})().then((permissions) => {
|
|
||||||
const ids = permissions.map((permission) => permission.sessionID)
|
const ids = permissions.map((permission) => permission.sessionID)
|
||||||
const grouped = groupBySession(
|
const grouped = groupBySession(
|
||||||
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||||
@@ -455,12 +400,10 @@ export async function bootstrapDirectory(input: {
|
|||||||
),
|
),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
(async () => {
|
input.api.question.request
|
||||||
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
|
.list({ location: { directory: input.directory } })
|
||||||
return input.api.question.request
|
.then((result) => result.data)
|
||||||
.list({ location: { directory: input.directory } })
|
.then((questions) => {
|
||||||
.then((result) => result.data)
|
|
||||||
})().then((questions) => {
|
|
||||||
const ids = questions.map((question) => question.sessionID)
|
const ids = questions.map((question) => question.sessionID)
|
||||||
const grouped = groupBySession(
|
const grouped = groupBySession(
|
||||||
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ describe("Home V2 session index", () => {
|
|||||||
const calls: unknown[] = []
|
const calls: unknown[] = []
|
||||||
const result = await loadHomeSessionIndex(async (input) => {
|
const result = await loadHomeSessionIndex(async (input) => {
|
||||||
calls.push(input)
|
calls.push(input)
|
||||||
return { data: { data: [session({ id: "root" })], cursor: {} } }
|
return { data: [session({ id: "root" })], cursor: {} }
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.sessions).toHaveLength(1)
|
expect(result.sessions).toHaveLength(1)
|
||||||
@@ -48,15 +48,13 @@ describe("Home V2 session index", () => {
|
|||||||
calls.push({ input, signal: options.signal })
|
calls.push({ input, signal: options.signal })
|
||||||
if (!("cursor" in input)) {
|
if (!("cursor" in input)) {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
|
||||||
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
|
session({ id: `page-1-${index}` }),
|
||||||
session({ id: `page-1-${index}` }),
|
),
|
||||||
),
|
cursor: { next: "next-page" },
|
||||||
cursor: { next: "next-page" },
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { data: { data: [session({ id: "page-2" })], cursor: {} } }
|
return { data: [session({ id: "page-2" })], cursor: {} }
|
||||||
},
|
},
|
||||||
0,
|
0,
|
||||||
controller.signal,
|
controller.signal,
|
||||||
@@ -145,10 +143,8 @@ describe("Home V2 session index", () => {
|
|||||||
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
|
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("refetches after reconnect, disposal, and session moves", () => {
|
test("refetches after reconnect", () => {
|
||||||
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
|
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
|
||||||
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
|
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
|
||||||
expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
|
|
||||||
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@/types"
|
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@/types"
|
||||||
|
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||||
import type { QueryClient } from "@tanstack/solid-query"
|
import type { QueryClient } from "@tanstack/solid-query"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
@@ -21,13 +22,11 @@ export type HomeSessionIndex = {
|
|||||||
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
|
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
|
||||||
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
|
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
|
||||||
|
|
||||||
type HomeSessionPage = { data?: V2SessionListResponse }
|
|
||||||
|
|
||||||
export async function loadHomeSessionIndex(
|
export async function loadHomeSessionIndex(
|
||||||
list: (
|
list: (
|
||||||
input: { limit: number; order: "desc"; cursor?: string },
|
input: { limit: number; order: "desc"; cursor?: string },
|
||||||
options: { signal?: AbortSignal },
|
options: { signal?: AbortSignal },
|
||||||
) => Promise<HomeSessionPage>,
|
) => Promise<V2SessionListResponse>,
|
||||||
eventSequence = 0,
|
eventSequence = 0,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
@@ -43,7 +42,7 @@ export async function loadHomeSessionIndex(
|
|||||||
},
|
},
|
||||||
{ signal },
|
{ signal },
|
||||||
)
|
)
|
||||||
const page = response.data!
|
const page = response
|
||||||
data.push(...page.data)
|
data.push(...page.data)
|
||||||
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
|
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
|
||||||
return { sessions: parseHomeSessionIndex(data), eventSequence }
|
return { sessions: parseHomeSessionIndex(data), eventSequence }
|
||||||
@@ -75,10 +74,7 @@ export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, ev
|
|||||||
|
|
||||||
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
|
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
|
||||||
if (event === "server.connected") return { connected: true, refetch: connected }
|
if (event === "server.connected") return { connected: true, refetch: connected }
|
||||||
return {
|
return { connected, refetch: false }
|
||||||
connected,
|
|
||||||
refetch: event === "global.disposed" || event === "session.next.moved",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
|
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
|
||||||
@@ -145,7 +141,7 @@ export function retainHomeSessions(sessions: Session[], limit: number, now: numb
|
|||||||
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
|
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
|
||||||
const info = event.properties.info
|
const info = event.properties.info
|
||||||
const index = sessions.findIndex((session) => session.id === info.id)
|
const index = sessions.findIndex((session) => session.id === info.id)
|
||||||
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
|
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
|
||||||
if (index === -1) return sessions
|
if (index === -1) return sessions
|
||||||
return sessions.toSpliced(index, 1)
|
return sessions.toSpliced(index, 1)
|
||||||
}
|
}
|
||||||
@@ -165,7 +161,7 @@ function toLegacySummary(session: SessionV2Info): Session {
|
|||||||
parentID: session.parentID,
|
parentID: session.parentID,
|
||||||
cost: session.cost,
|
cost: session.cost,
|
||||||
tokens: session.tokens,
|
tokens: session.tokens,
|
||||||
title: session.title,
|
title: withTimestampedFallback(session),
|
||||||
agent: session.agent,
|
agent: session.agent,
|
||||||
model: session.model,
|
model: session.model,
|
||||||
version: "",
|
version: "",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { SessionApi } from "@opencode-ai/client/promise"
|
import type { SessionApi } from "@opencode-ai/client/promise"
|
||||||
import { normalizeSessionInfo } from "@/utils/session"
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
|
|
||||||
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
|
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
|
||||||
const result = await input.api.list({
|
const result = await input.api.list({
|
||||||
@@ -16,16 +15,6 @@ export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; d
|
|||||||
} as const
|
} as const
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
|
|
||||||
try {
|
|
||||||
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
|
|
||||||
return { data: result.data, limit: input.limit, limited: true } as const
|
|
||||||
} catch {
|
|
||||||
const result = await input.client.session.list({ directory: input.directory, roots: true })
|
|
||||||
return { data: result.data, limit: input.limit, limited: false } as const
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
|
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
|
||||||
if (!input.limited) return input.count
|
if (!input.limited) return input.count
|
||||||
if (input.count < input.limit) return input.count
|
if (input.count < input.limit) return input.count
|
||||||
|
|||||||
@@ -58,20 +58,7 @@ export function normalizeProviderList(
|
|||||||
defaultModel?: ModelDefaultOutput["data"],
|
defaultModel?: ModelDefaultOutput["data"],
|
||||||
): NormalizedProviderListResponse {
|
): NormalizedProviderListResponse {
|
||||||
if (!Array.isArray(providers)) {
|
if (!Array.isArray(providers)) {
|
||||||
return {
|
return providers
|
||||||
...providers,
|
|
||||||
all: new Map(
|
|
||||||
providers.all.map((provider) => [
|
|
||||||
provider.id,
|
|
||||||
{
|
|
||||||
...provider,
|
|
||||||
models: Object.fromEntries(
|
|
||||||
Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const all = new Map<string, Provider>()
|
const all = new Map<string, Provider>()
|
||||||
|
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
void removePersisted(target, platform)
|
void removePersisted(target, platform)
|
||||||
|
|
||||||
if (scope !== ServerScope.local) continue
|
if (scope !== ServerScope.local) continue
|
||||||
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
|
const legacyKey = `${dir}/${entry["legacy"]}${session ? "/" + session : ""}.${entry.version}`
|
||||||
void removePersisted({ key: legacyKey }, platform)
|
void removePersisted({ key: legacyKey }, platform)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -572,17 +572,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const projectID = project.id
|
const projectID = project.id
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const sdk = serverSdk()
|
// TODO: Restore project color updates when the V2 client exposes a project update API.
|
||||||
if ((await sdk.protocol) !== "v1") return
|
void projectID
|
||||||
return sdk.client.project
|
|
||||||
.update({ projectID, directory: worktree, icon: { color } })
|
|
||||||
.then((response) => response.data)
|
|
||||||
.then((result) => {
|
|
||||||
if (!result) return
|
|
||||||
serverSync().set("project", (items) =>
|
|
||||||
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})().catch(() => {
|
})().catch(() => {
|
||||||
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -212,7 +212,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
|||||||
)
|
)
|
||||||
|
|
||||||
function enableConfiguredDirectory(directory: string) {
|
function enableConfiguredDirectory(directory: string) {
|
||||||
if (input.sdk.protocolKind() !== "v1") return
|
|
||||||
if (meta.disposed || !ready()) return
|
if (meta.disposed || !ready()) return
|
||||||
const [childStore] = input.sync.child(directory)
|
const [childStore] = input.sync.child(directory)
|
||||||
if (childStore.config.permission !== "allow") return
|
if (childStore.config.permission !== "allow") return
|
||||||
@@ -250,7 +249,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
|||||||
sessionID: request.sessionID,
|
sessionID: request.sessionID,
|
||||||
requestID: request.permissionID,
|
requestID: request.permissionID,
|
||||||
reply: request.response,
|
reply: request.response,
|
||||||
location: request.directory ? { directory: request.directory } : undefined,
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
responded.delete(request.permissionID)
|
responded.delete(request.permissionID)
|
||||||
@@ -258,9 +256,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
|||||||
}
|
}
|
||||||
|
|
||||||
const list = async (directory: string) => {
|
const list = async (directory: string) => {
|
||||||
if ((await input.sdk.protocol) === "v1") {
|
|
||||||
return (await input.sdk.client.permission.list({ directory })).data ?? []
|
|
||||||
}
|
|
||||||
return input.sdk.api.permission.request
|
return input.sdk.api.permission.request
|
||||||
.list({ location: { directory } })
|
.list({ location: { directory } })
|
||||||
.then((result) => result.data.map(normalizePermissionRequest))
|
.then((result) => result.data.map(normalizePermissionRequest))
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { type Accessor, createMemo } from "solid-js"
|
import { type Accessor, createMemo } from "solid-js"
|
||||||
import { type DirectorySDK, useServerSDK } from "./server-sdk"
|
import { type DirectorySDK, useServerSDK } from "./server-sdk"
|
||||||
|
export type { DirectorySDK } from "./server-sdk"
|
||||||
|
|
||||||
export type { DirectorySDK }
|
const context = createSimpleContext({
|
||||||
|
|
||||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|
||||||
name: "SDK",
|
name: "SDK",
|
||||||
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
|
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
|
||||||
init: (props: { directory: string | Accessor<string> }) => {
|
init: (props: { directory: string | Accessor<string> }) => {
|
||||||
@@ -15,3 +14,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const useSDK: () => Accessor<DirectorySDK> = context.use
|
||||||
|
export const SDKProvider = context.provider
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
|
||||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||||
import type { Event } from "@/types"
|
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||||
|
|
||||||
describe("resumeStreamAfterPageShow", () => {
|
describe("resumeStreamAfterPageShow", () => {
|
||||||
test("restarts a stream only after a back-forward cache restore", () => {
|
test("restarts a stream only after a back-forward cache restore", () => {
|
||||||
@@ -16,7 +15,7 @@ describe("resumeStreamAfterPageShow", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("adaptServerEvent", () => {
|
describe("adaptServerEvent", () => {
|
||||||
test("preserves V2 events while adapting permission requests for existing consumers", () => {
|
test("preserves current events while adapting permission requests for existing consumers", () => {
|
||||||
const current = {
|
const current = {
|
||||||
id: "evt_1",
|
id: "evt_1",
|
||||||
created: 1,
|
created: 1,
|
||||||
@@ -31,6 +30,7 @@ describe("adaptServerEvent", () => {
|
|||||||
} as OpenCodeEvent
|
} as OpenCodeEvent
|
||||||
|
|
||||||
expect(adaptServerEvent(current)).toMatchObject({
|
expect(adaptServerEvent(current)).toMatchObject({
|
||||||
|
id: "evt_1",
|
||||||
type: "permission.asked",
|
type: "permission.asked",
|
||||||
properties: {
|
properties: {
|
||||||
id: "perm_1",
|
id: "perm_1",
|
||||||
@@ -44,42 +44,24 @@ describe("adaptServerEvent", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("coalesceServerEvents", () => {
|
describe("current event buffering", () => {
|
||||||
const delta = (value: string, field = "text", partID = "part") => ({
|
const delta = (id: string, value: string, ordinal = 0) => ({
|
||||||
directory: "/repo",
|
directory: "/repo",
|
||||||
payload: {
|
payload: adaptServerEvent({
|
||||||
type: "message.part.delta",
|
id,
|
||||||
properties: { messageID: "msg", partID, field, delta: value },
|
created: 1,
|
||||||
} as Event,
|
type: "session.text.delta",
|
||||||
|
location: { directory: "/repo" },
|
||||||
|
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
||||||
|
} as OpenCodeEvent),
|
||||||
})
|
})
|
||||||
|
|
||||||
test("merges adjacent deltas for the same field", () => {
|
test("merges adjacent text deltas for the same message and ordinal", () => {
|
||||||
const first = delta("hello ")
|
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
||||||
const second = delta("world")
|
|
||||||
first.payload.id = "first"
|
|
||||||
second.payload.id = "second"
|
|
||||||
const result = coalesceServerEvents([first, second])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("merges adjacent current text deltas", () => {
|
|
||||||
const current = (id: string, value: string) =>
|
|
||||||
adaptServerEvent({
|
|
||||||
id,
|
|
||||||
created: 1,
|
|
||||||
type: "session.text.delta",
|
|
||||||
location: { directory: "/repo" },
|
|
||||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
|
|
||||||
} as OpenCodeEvent)
|
|
||||||
const result = coalesceServerEvents([
|
|
||||||
{ directory: "/repo", payload: current("evt_1", "hello ") },
|
|
||||||
{ directory: "/repo", payload: current("evt_2", "world") },
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||||
|
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("coalesces current tool input deltas by tool ID", () => {
|
test("coalesces current tool input deltas by tool ID", () => {
|
||||||
@@ -102,127 +84,20 @@ describe("coalesceServerEvents", () => {
|
|||||||
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves event boundaries and distinct fields", () => {
|
test("preserves boundaries between distinct delta streams", () => {
|
||||||
const status = {
|
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
||||||
directory: "/repo",
|
|
||||||
payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event,
|
|
||||||
}
|
|
||||||
const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")])
|
|
||||||
|
|
||||||
expect(result.map((event) => event.payload.type)).toEqual([
|
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual([
|
||||||
"message.part.delta",
|
"evt_1",
|
||||||
"message.part.delta",
|
"evt_2",
|
||||||
"session.status",
|
"evt_3",
|
||||||
"message.part.delta",
|
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves event ID order across interleaved deltas", () => {
|
test("preserves current event order when enqueuing", () => {
|
||||||
const first = delta("a")
|
const events: Parameters<typeof enqueueServerEvent>[0] = []
|
||||||
const other = delta("b", "text", "other")
|
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
|
||||||
const last = delta("c")
|
|
||||||
first.payload.id = "1"
|
|
||||||
other.payload.id = "2"
|
|
||||||
last.payload.id = "3"
|
|
||||||
|
|
||||||
const result = coalesceServerEvents([first, other, last])
|
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
|
||||||
|
|
||||||
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("enqueueServerEvent", () => {
|
|
||||||
const partUpdated = (text: string) =>
|
|
||||||
({
|
|
||||||
type: "message.part.updated",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session",
|
|
||||||
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
|
|
||||||
},
|
|
||||||
}) as Event
|
|
||||||
|
|
||||||
test("preserves part updates across message remove and re-add barriers", () => {
|
|
||||||
const events: Array<{ directory: string; payload: Event }> = []
|
|
||||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
|
||||||
|
|
||||||
enqueue(partUpdated("old"))
|
|
||||||
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
|
|
||||||
enqueue({
|
|
||||||
type: "message.updated",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session",
|
|
||||||
info: {
|
|
||||||
id: "message",
|
|
||||||
sessionID: "session",
|
|
||||||
role: "user",
|
|
||||||
time: { created: 1 },
|
|
||||||
agent: "build",
|
|
||||||
model: { providerID: "provider", modelID: "model" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as Event)
|
|
||||||
enqueue(partUpdated("new"))
|
|
||||||
|
|
||||||
expect(events.map((event) => event.payload.type)).toEqual([
|
|
||||||
"message.part.updated",
|
|
||||||
"message.removed",
|
|
||||||
"message.updated",
|
|
||||||
"message.part.updated",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves deltas after a replacement snapshot", () => {
|
|
||||||
const events: Array<{ directory: string; payload: Event }> = []
|
|
||||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
|
||||||
|
|
||||||
enqueue(partUpdated("a"))
|
|
||||||
enqueue(partUpdated("ab"))
|
|
||||||
enqueue({
|
|
||||||
type: "message.part.delta",
|
|
||||||
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
|
|
||||||
} as Event)
|
|
||||||
|
|
||||||
const result = coalesceServerEvents(events)
|
|
||||||
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
|
|
||||||
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
|
|
||||||
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves updates after session deletion", () => {
|
|
||||||
const events: Array<{ directory: string; payload: Event }> = []
|
|
||||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
|
||||||
|
|
||||||
enqueue(partUpdated("old"))
|
|
||||||
enqueue({
|
|
||||||
type: "session.deleted",
|
|
||||||
properties: { sessionID: "session", info: { id: "session" } },
|
|
||||||
} as Event)
|
|
||||||
enqueue(partUpdated("new"))
|
|
||||||
|
|
||||||
expect(events.map((event) => event.payload.type)).toEqual([
|
|
||||||
"message.part.updated",
|
|
||||||
"session.deleted",
|
|
||||||
"message.part.updated",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not coalesce edge-triggered session statuses", () => {
|
|
||||||
const events: Array<{ directory: string; payload: Event }> = []
|
|
||||||
const enqueue = (status: "retry" | "busy") =>
|
|
||||||
enqueueServerEvent(events, {
|
|
||||||
directory: "/repo",
|
|
||||||
payload: {
|
|
||||||
type: "session.status",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session",
|
|
||||||
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
|
|
||||||
},
|
|
||||||
} as Event,
|
|
||||||
})
|
|
||||||
|
|
||||||
enqueue("retry")
|
|
||||||
enqueue("busy")
|
|
||||||
|
|
||||||
expect(events).toHaveLength(2)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,17 +3,14 @@ import type { Event, PermissionRequest } from "@/types"
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { type Accessor, batch, createMemo, createResource, onCleanup, onMount } from "solid-js"
|
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||||
import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server"
|
import { createApiForServer, type ServerApi } from "@/utils/server"
|
||||||
import { useLanguage } from "./language"
|
import { useLanguage } from "./language"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { ServerConnection, useServer } from "./server"
|
import { ServerConnection, useServer } from "./server"
|
||||||
import { createRefCountMap } from "@/utils/refcount"
|
import { createRefCountMap } from "@/utils/refcount"
|
||||||
import { useGlobal } from "./global"
|
import { useGlobal } from "./global"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
|
|
||||||
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
|
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
|
|
||||||
const isAbortError = (error: unknown) =>
|
const isAbortError = (error: unknown) =>
|
||||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||||
@@ -49,22 +46,7 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
|||||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
const coalescedKey = (event: QueuedServerEvent) => {
|
|
||||||
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
|
||||||
if (event.payload.type === "message.part.updated") {
|
|
||||||
const part = event.payload.properties.part
|
|
||||||
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
|
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
|
||||||
const key = coalescedKey(event)
|
|
||||||
const previous = queue[queue.length - 1]
|
|
||||||
if (key && previous && coalescedKey(previous) === key) {
|
|
||||||
queue[queue.length - 1] = event
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
queue.push(event)
|
queue.push(event)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -100,33 +82,7 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
|||||||
output.push(event)
|
output.push(event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.payload.type !== "message.part.delta") {
|
output.push(event)
|
||||||
output.push(event)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const props = event.payload.properties
|
|
||||||
const previous = output[output.length - 1]
|
|
||||||
if (
|
|
||||||
!previous ||
|
|
||||||
previous.payload.type !== "message.part.delta" ||
|
|
||||||
previous.directory !== event.directory ||
|
|
||||||
previous.payload.properties.messageID !== props.messageID ||
|
|
||||||
previous.payload.properties.partID !== props.partID ||
|
|
||||||
previous.payload.properties.field !== props.field
|
|
||||||
) {
|
|
||||||
output.push({
|
|
||||||
directory: event.directory,
|
|
||||||
payload: { ...event.payload, properties: { ...props } },
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
output[output.length - 1] = {
|
|
||||||
directory: event.directory,
|
|
||||||
payload: {
|
|
||||||
...event.payload,
|
|
||||||
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
@@ -161,20 +117,13 @@ type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]:
|
|||||||
type ServerSDKBase = {
|
type ServerSDKBase = {
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
protocol: Promise<ServerProtocol>
|
|
||||||
protocolKind: Accessor<ServerProtocol | undefined>
|
|
||||||
url: string
|
url: string
|
||||||
client: ReturnType<typeof createSdkForServer>
|
api: ServerApi
|
||||||
api: CompatibleApi
|
|
||||||
currentApi: ServerApi
|
|
||||||
event: {
|
event: {
|
||||||
on: ServerEventEmitter["on"]
|
on: ServerEventEmitter["on"]
|
||||||
listen: ServerEventEmitter["listen"]
|
listen: ServerEventEmitter["listen"]
|
||||||
start: () => Promise<void> | undefined
|
start: () => Promise<void> | undefined
|
||||||
}
|
}
|
||||||
createClient: (
|
|
||||||
opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">,
|
|
||||||
) => ReturnType<typeof createSdkForServer>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||||
@@ -193,11 +142,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
|
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
|
||||||
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
|
|
||||||
const [protocolKind] = createResource(
|
|
||||||
() => protocol,
|
|
||||||
(value) => value,
|
|
||||||
)
|
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<{
|
||||||
[key: string]: ServerEvent
|
[key: string]: ServerEvent
|
||||||
}>()
|
}>()
|
||||||
@@ -315,63 +259,23 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
flush()
|
flush()
|
||||||
})
|
})
|
||||||
|
|
||||||
const sdk = createSdkForServer({
|
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||||
server: server.http,
|
|
||||||
fetch: platform.fetch,
|
|
||||||
throwOnError: true,
|
|
||||||
})
|
|
||||||
const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch })
|
|
||||||
const legacy = (directory?: string) =>
|
|
||||||
createSdkForServer({
|
|
||||||
server: server.http,
|
|
||||||
fetch: platform.fetch,
|
|
||||||
throwOnError: true,
|
|
||||||
directory,
|
|
||||||
})
|
|
||||||
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
scope,
|
scope,
|
||||||
protocol,
|
|
||||||
protocolKind,
|
|
||||||
url: server.http.url,
|
url: server.http.url,
|
||||||
client: sdk,
|
|
||||||
api,
|
api,
|
||||||
currentApi,
|
|
||||||
event: {
|
event: {
|
||||||
on: emitter.on.bind(emitter),
|
on: emitter.on.bind(emitter),
|
||||||
listen: emitter.listen.bind(emitter),
|
listen: emitter.listen.bind(emitter),
|
||||||
start,
|
start,
|
||||||
},
|
},
|
||||||
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
|
||||||
return createSdkForServer({
|
|
||||||
server: server.http,
|
|
||||||
fetch: platform.fetch,
|
|
||||||
...opts,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type SDKEventMap = {
|
|
||||||
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DirectorySDK = {
|
|
||||||
scope: ServerScope
|
|
||||||
protocol: Promise<ServerProtocol>
|
|
||||||
directory: string
|
|
||||||
client: OpencodeClient
|
|
||||||
currentApi: ServerApi
|
|
||||||
api: CompatibleApi
|
|
||||||
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
|
|
||||||
readonly url: string
|
|
||||||
createClient: ServerSDKBase["createClient"]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServerSDK = ServerSDKBase & {
|
export type ServerSDK = ServerSDKBase & {
|
||||||
ensureDirSdkContext: (directory: string) => DirectorySDK
|
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
|
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
|
||||||
@@ -398,17 +302,19 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export function useServerProtocol() {
|
type SDKEventMap = {
|
||||||
const serverSDK = useServerSDK()
|
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
||||||
return createMemo(() => serverSDK().protocolKind())
|
}
|
||||||
|
|
||||||
|
export type DirectorySDK = {
|
||||||
|
scope: ServerScope
|
||||||
|
directory: string
|
||||||
|
api: ServerApi
|
||||||
|
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
|
||||||
|
readonly url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
|
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
|
||||||
const client = serverSDK.createClient({
|
|
||||||
directory,
|
|
||||||
throwOnError: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||||
|
|
||||||
const unsub = serverSDK.event.on(directory, (event) => {
|
const unsub = serverSDK.event.on(directory, (event) => {
|
||||||
@@ -418,22 +324,11 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): Direc
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
protocol: serverSDK.protocol,
|
|
||||||
directory,
|
directory,
|
||||||
client,
|
api: serverSDK.api,
|
||||||
currentApi: serverSDK.currentApi,
|
|
||||||
api: createCompatibleApi({
|
|
||||||
protocol: serverSDK.protocol,
|
|
||||||
current: serverSDK.currentApi,
|
|
||||||
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
|
||||||
directory,
|
|
||||||
}),
|
|
||||||
event: emitter,
|
event: emitter,
|
||||||
get url() {
|
get url() {
|
||||||
return serverSDK.url
|
return serverSDK.url
|
||||||
},
|
},
|
||||||
createClient(opts: Parameters<typeof serverSDK.createClient>[0]) {
|
|
||||||
return serverSDK.createClient(opts)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { retry } from "@opencode-ai/core/util/retry"
|
import type { retry } from "@opencode-ai/core/util/retry"
|
||||||
import type { OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise"
|
import type {
|
||||||
|
OpenCodeEvent,
|
||||||
|
SessionApi,
|
||||||
|
SessionInfo,
|
||||||
|
SessionMessageAssistant,
|
||||||
|
SessionMessageAssistantTool,
|
||||||
|
SessionMessageInfo,
|
||||||
|
} from "@opencode-ai/client/promise"
|
||||||
import type { Message, Part, Session } from "@/types"
|
import type { Message, Part, Session } from "@/types"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { createServerSession } from "./server-session"
|
import { createServerSession } from "./server-session"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
@@ -22,19 +28,130 @@ const session = (id: string, parentID?: string): Session => ({
|
|||||||
type UserMessage = Extract<Message, { role: "user" }>
|
type UserMessage = Extract<Message, { role: "user" }>
|
||||||
type AssistantMessage = Extract<Message, { role: "assistant" }>
|
type AssistantMessage = Extract<Message, { role: "assistant" }>
|
||||||
type TextPart = Extract<Part, { type: "text" }>
|
type TextPart = Extract<Part, { type: "text" }>
|
||||||
|
type CurrentToolObject = Extract<SessionMessageAssistantTool["state"], { status: "running" }>["input"]
|
||||||
type MessageResponse = {
|
type MessageResponse = {
|
||||||
data: { info: Message; parts: Part[] }[]
|
data: { info: Message; parts: Part[] }[]
|
||||||
response: { headers: Headers }
|
response: { headers: Headers }
|
||||||
}
|
}
|
||||||
type SingleMessageResponse = { data: MessageResponse["data"][number] }
|
type SingleMessageResponse = { data: MessageResponse["data"][number] }
|
||||||
|
|
||||||
|
function sessionInfo(value: Session): SessionInfo {
|
||||||
|
return {
|
||||||
|
id: value.id,
|
||||||
|
parentID: value.parentID,
|
||||||
|
projectID: value.projectID,
|
||||||
|
cost: value.cost ?? 0,
|
||||||
|
tokens: value.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: value.time,
|
||||||
|
title: value.title,
|
||||||
|
location: { directory: value.directory, workspaceID: value.workspaceID },
|
||||||
|
subpath: value.path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentMessages(data: MessageResponse["data"]): SessionMessageInfo[] {
|
||||||
|
return data.flatMap((item): SessionMessageInfo[] => {
|
||||||
|
if (item.info.role === "user") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `${item.info.id}:agent`,
|
||||||
|
type: "agent-switched",
|
||||||
|
agent: item.info.agent,
|
||||||
|
time: item.info.time,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${item.info.id}:model`,
|
||||||
|
type: "model-switched",
|
||||||
|
model: {
|
||||||
|
id: item.info.model.modelID,
|
||||||
|
providerID: item.info.model.providerID,
|
||||||
|
variant: item.info.model.variant,
|
||||||
|
},
|
||||||
|
time: item.info.time,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: item.info.id,
|
||||||
|
type: "user",
|
||||||
|
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||||
|
time: item.info.time,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [{
|
||||||
|
id: item.info.id,
|
||||||
|
type: "assistant",
|
||||||
|
agent: item.info.agent,
|
||||||
|
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
|
||||||
|
content: item.parts.flatMap((part): SessionMessageAssistant["content"] => {
|
||||||
|
if (part.type === "text") return [{ type: "text", text: part.text }]
|
||||||
|
if (part.type === "reasoning")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: part.text,
|
||||||
|
time: part.time ? { created: part.time.start, completed: part.time.end } : undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (part.type !== "tool") return []
|
||||||
|
const state: SessionMessageAssistantTool["state"] = (() => {
|
||||||
|
if (part.state.status === "pending") return { status: "streaming" as const, input: JSON.stringify(part.state.input) }
|
||||||
|
if (part.state.status === "running")
|
||||||
|
return {
|
||||||
|
status: "running" as const,
|
||||||
|
input: part.state.input as CurrentToolObject,
|
||||||
|
metadata: (part.state.metadata ?? {}) as CurrentToolObject,
|
||||||
|
}
|
||||||
|
if (part.state.status === "error")
|
||||||
|
return {
|
||||||
|
status: "error" as const,
|
||||||
|
input: part.state.input as CurrentToolObject,
|
||||||
|
error: { type: "tool_error", message: part.state.error },
|
||||||
|
metadata: part.state.metadata as CurrentToolObject | undefined,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "completed" as const,
|
||||||
|
input: part.state.input as CurrentToolObject,
|
||||||
|
content: [{ type: "text" as const, text: part.state.output }],
|
||||||
|
metadata: part.state.metadata as CurrentToolObject,
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: part.id,
|
||||||
|
type: "tool" as const,
|
||||||
|
name: part.tool,
|
||||||
|
state,
|
||||||
|
time: {
|
||||||
|
created: part.state.status === "pending" ? item.info.time.created : part.state.time.start,
|
||||||
|
ran: part.state.status === "pending" ? undefined : part.state.time.start,
|
||||||
|
completed:
|
||||||
|
part.state.status === "completed" || part.state.status === "error" ? part.state.time.end : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
time: item.info.time,
|
||||||
|
cost: item.info.cost,
|
||||||
|
tokens: item.info.tokens,
|
||||||
|
finish: item.info.finish as "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | undefined,
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentPage(value: MessageResponse) {
|
||||||
|
return {
|
||||||
|
data: currentMessages(value.data).toReversed(),
|
||||||
|
cursor: { next: value.response.headers.get("x-next-cursor") ?? undefined },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({
|
const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({
|
||||||
id,
|
id,
|
||||||
sessionID: "child",
|
sessionID: "child",
|
||||||
role: "user",
|
role: "user",
|
||||||
time: { created: 1 },
|
time: { created: 1 },
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model: { providerID: "provider", modelID: "model" },
|
model: { providerID: "provider", modelID: "model", variant: undefined },
|
||||||
...input,
|
...input,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -46,21 +163,24 @@ const assistantMessage = (id: string, parentID: string, input: Partial<Assistant
|
|||||||
parentID,
|
parentID,
|
||||||
modelID: "model",
|
modelID: "model",
|
||||||
providerID: "provider",
|
providerID: "provider",
|
||||||
|
variant: undefined,
|
||||||
mode: "build",
|
mode: "build",
|
||||||
agent: "build",
|
agent: "build",
|
||||||
path: { cwd: "/repo", root: "/repo" },
|
path: { cwd: "", root: "" },
|
||||||
cost: 0,
|
cost: 0,
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
error: undefined,
|
||||||
|
finish: undefined,
|
||||||
...input,
|
...input,
|
||||||
})
|
})
|
||||||
|
|
||||||
const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
|
const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
|
||||||
id: "part",
|
|
||||||
sessionID: "child",
|
sessionID: "child",
|
||||||
messageID,
|
messageID,
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "text",
|
text: "text",
|
||||||
...input,
|
...input,
|
||||||
|
id: `${messageID}:text:${input.id === "pending" ? 1 : 0}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({
|
const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({
|
||||||
@@ -74,19 +194,27 @@ const deferredResponse = () => Promise.withResolvers<MessageResponse>()
|
|||||||
|
|
||||||
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
|
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
|
||||||
let index = 0
|
let index = 0
|
||||||
|
const pages = responses.map((value) =>
|
||||||
|
value instanceof Promise ? value.then(currentPage) : Promise.resolve(currentPage(value)),
|
||||||
|
)
|
||||||
const requests: unknown[] = []
|
const requests: unknown[] = []
|
||||||
const waiting = new Map<number, () => void>()
|
const waiting = new Map<number, () => void>()
|
||||||
const client = {
|
const client = {
|
||||||
session: {
|
session: {
|
||||||
get: async () => ({ data: session("child", "root") }),
|
get: async () => sessionInfo(session("child", "root")),
|
||||||
messages: (input: unknown) => {
|
message: async () => {
|
||||||
|
throw new Error("Unexpected single message request")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
list: async (input: unknown) => {
|
||||||
requests.push(input)
|
requests.push(input)
|
||||||
waiting.get(requests.length)?.()
|
waiting.get(requests.length)?.()
|
||||||
waiting.delete(requests.length)
|
waiting.delete(requests.length)
|
||||||
return responses[index++]
|
return pages[index++]!
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as OpencodeClient
|
} as unknown as { session: SessionApi; message: MessageApi }
|
||||||
return Object.assign(client, {
|
return Object.assign(client, {
|
||||||
requests,
|
requests,
|
||||||
requested(count: number) {
|
requested(count: number) {
|
||||||
@@ -107,19 +235,22 @@ function rootMessageClient(
|
|||||||
const rootWaiting = new Map<number, () => void>()
|
const rootWaiting = new Map<number, () => void>()
|
||||||
const client = {
|
const client = {
|
||||||
session: {
|
session: {
|
||||||
get: async () => ({ data: session("child", "root") }),
|
get: async () => sessionInfo(session("child", "root")),
|
||||||
messages: (input: unknown) => {
|
message: async (input: unknown) => {
|
||||||
requests.push(input)
|
|
||||||
return pages[pageIndex++]
|
|
||||||
},
|
|
||||||
message: (input: unknown) => {
|
|
||||||
rootRequests.push(input)
|
rootRequests.push(input)
|
||||||
rootWaiting.get(rootRequests.length)?.()
|
rootWaiting.get(rootRequests.length)?.()
|
||||||
rootWaiting.delete(rootRequests.length)
|
rootWaiting.delete(rootRequests.length)
|
||||||
return roots[rootIndex++]
|
const value = await roots[rootIndex++]!
|
||||||
|
return currentMessages([value.data]).find((message) => message.id === value.data.info.id)!
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as OpencodeClient
|
message: {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
requests.push(input)
|
||||||
|
return currentPage(await pages[pageIndex++]!)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as { session: SessionApi; message: MessageApi }
|
||||||
return Object.assign(client, {
|
return Object.assign(client, {
|
||||||
requests,
|
requests,
|
||||||
rootRequests,
|
rootRequests,
|
||||||
@@ -149,16 +280,19 @@ function setup(sessions: Record<string, Session>) {
|
|||||||
get: async (input: unknown) => {
|
get: async (input: unknown) => {
|
||||||
get.push(input)
|
get.push(input)
|
||||||
const id = (input as { sessionID: string }).sessionID
|
const id = (input as { sessionID: string }).sessionID
|
||||||
return { data: sessions[id] }
|
return sessionInfo(sessions[id]!)
|
||||||
},
|
},
|
||||||
messages: async (input: unknown) => {
|
message: async () => {
|
||||||
messages.push(input)
|
throw new Error("Unexpected single message request")
|
||||||
return response()
|
|
||||||
},
|
},
|
||||||
diff: async () => ({ data: [] }),
|
|
||||||
todo: async () => ({ data: [] }),
|
|
||||||
},
|
},
|
||||||
} as unknown as OpencodeClient
|
message: {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
messages.push(input)
|
||||||
|
return currentPage(response())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as { session: SessionApi; message: MessageApi }
|
||||||
return { get, messages, store: createServerSession(client) }
|
return { get, messages, store: createServerSession(client) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +364,7 @@ describe("server session", () => {
|
|||||||
await ctx.store.sync("root")
|
await ctx.store.sync("root")
|
||||||
|
|
||||||
expect(ctx.get).toEqual([{ sessionID: "root" }])
|
expect(ctx.get).toEqual([{ sessionID: "root" }])
|
||||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }])
|
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
||||||
expect(ctx.store.data.message.root).toEqual([])
|
expect(ctx.store.data.message.root).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -245,54 +379,19 @@ describe("server session", () => {
|
|||||||
content: [{ type: "text", text: "hi" }],
|
content: [{ type: "text", text: "hi" }],
|
||||||
time: { created: 2, completed: 3 },
|
time: { created: 2, completed: 3 },
|
||||||
}
|
}
|
||||||
const client = {
|
|
||||||
session: {
|
|
||||||
messages: () => {
|
|
||||||
throw new Error("legacy message endpoint called")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as OpencodeClient
|
|
||||||
const messageApi = {
|
const messageApi = {
|
||||||
list: async (input: unknown) => {
|
list: async (input: unknown) => {
|
||||||
requests.push(input)
|
requests.push(input)
|
||||||
return { data: [assistant, user], cursor: { previous: null, next: null } }
|
return { data: [assistant, user], cursor: { previous: null, next: null } }
|
||||||
},
|
},
|
||||||
} as unknown as MessageApi
|
} as unknown as MessageApi
|
||||||
const store = createServerSession(client, {} as SessionApi, messageApi)
|
const store = createServerSession({} as SessionApi, messageApi)
|
||||||
store.remember(session("root"))
|
store.remember(session("root"))
|
||||||
|
|
||||||
await store.sync("root")
|
await store.sync("root")
|
||||||
|
|
||||||
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
||||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||||
expect(store.history.more("root")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("replaces stale current projections on complete refreshes", async () => {
|
|
||||||
const first = { id: "msg_1", type: "user", text: "first", time: { created: 1 } } as const
|
|
||||||
const second = { id: "msg_2", type: "user", text: "second", time: { created: 2 } } as const
|
|
||||||
const pages = [
|
|
||||||
{ data: [first], cursor: { previous: null, next: null } },
|
|
||||||
{ data: [second], cursor: { previous: null, next: null } },
|
|
||||||
{ data: [], cursor: { previous: null, next: null } },
|
|
||||||
]
|
|
||||||
const messageApi = {
|
|
||||||
list: async () => pages.shift()!,
|
|
||||||
} as unknown as MessageApi
|
|
||||||
const sessionApi = { get: async () => session("root") } as unknown as SessionApi
|
|
||||||
const store = createServerSession({} as OpencodeClient, sessionApi, messageApi)
|
|
||||||
store.remember(session("root"))
|
|
||||||
|
|
||||||
await store.sync("root")
|
|
||||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([first.id])
|
|
||||||
|
|
||||||
await store.sync("root", { force: true })
|
|
||||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([second.id])
|
|
||||||
expect(store.data.message.root.map((message) => message.id)).toEqual([second.id])
|
|
||||||
|
|
||||||
await store.sync("root", { force: true })
|
|
||||||
expect(store.data.session_message.root).toEqual([])
|
|
||||||
expect(store.data.message.root).toEqual([])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("extends a current page to include the user for split assistant turns", async () => {
|
test("extends a current page to include the user for split assistant turns", async () => {
|
||||||
@@ -321,7 +420,7 @@ describe("server session", () => {
|
|||||||
return pages.shift()!
|
return pages.shift()!
|
||||||
},
|
},
|
||||||
} as unknown as MessageApi
|
} as unknown as MessageApi
|
||||||
const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi)
|
const store = createServerSession({} as SessionApi, messageApi)
|
||||||
store.remember(session("root"))
|
store.remember(session("root"))
|
||||||
|
|
||||||
await store.sync("root")
|
await store.sync("root")
|
||||||
@@ -337,50 +436,14 @@ describe("server session", () => {
|
|||||||
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
|
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("indexes V1 messages for the current timeline projection", async () => {
|
// V2 messages are ordered projections and do not expose V1 assistant parent IDs.
|
||||||
const user = userMessage("message-1", { sessionID: "root" })
|
describe.skip("V1 assistant parent projections", () => {
|
||||||
const assistant = assistantMessage("message-2", user.id, { sessionID: "root" })
|
|
||||||
const client = messageClient(
|
|
||||||
response([
|
|
||||||
{ info: user, parts: [textPart(user.id, { sessionID: "root" })] },
|
|
||||||
{ info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] },
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
const messageApi = {
|
|
||||||
list: () => {
|
|
||||||
throw new Error("current message endpoint called")
|
|
||||||
},
|
|
||||||
} as unknown as MessageApi
|
|
||||||
const store = createServerSession(client, {} as SessionApi, messageApi, {
|
|
||||||
protocol: Promise.resolve("v1"),
|
|
||||||
})
|
|
||||||
store.remember(session("root"))
|
|
||||||
|
|
||||||
await store.sync("root")
|
|
||||||
|
|
||||||
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
|
||||||
expect(store.data.session_message.root).toMatchObject([
|
|
||||||
{ id: user.id, type: "user", text: "text" },
|
|
||||||
{ id: assistant.id, type: "assistant" },
|
|
||||||
])
|
|
||||||
|
|
||||||
const next = userMessage("message-3", { sessionID: "root" })
|
|
||||||
store.apply({ type: "message.updated", properties: { info: next } })
|
|
||||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id])
|
|
||||||
|
|
||||||
store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } })
|
|
||||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("backfills an assistant-only initial page through its user root", async () => {
|
test("backfills an assistant-only initial page through its user root", async () => {
|
||||||
const user = userMessage("message-1")
|
const user = userMessage("message-1")
|
||||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||||
const client = rootMessageClient(
|
const client = rootMessageClient(
|
||||||
[
|
[
|
||||||
response(
|
response(assistants.map((info) => ({ info, parts: [] }))),
|
||||||
assistants.map((info) => ({ info, parts: [] })),
|
|
||||||
"older",
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
[singleResponse(user)],
|
[singleResponse(user)],
|
||||||
)
|
)
|
||||||
@@ -388,16 +451,16 @@ describe("server session", () => {
|
|||||||
|
|
||||||
await store.sync("child")
|
await store.sync("child")
|
||||||
|
|
||||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }])
|
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }])
|
||||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||||
expect(store.history.more("child")).toBe(true)
|
expect(store.history.more("child")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
|
test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
|
||||||
const missing = Promise.withResolvers<SingleMessageResponse>()
|
const missing = Promise.withResolvers<SingleMessageResponse>()
|
||||||
const assistant = assistantMessage("message-2", "message-missing")
|
const assistant = assistantMessage("message-2", "message-missing")
|
||||||
const client = rootMessageClient([response([{ info: assistant, parts: [] }], "older")], [missing.promise])
|
const client = rootMessageClient([response([{ info: assistant, parts: [] }])], [missing.promise])
|
||||||
const store = createServerSession(client)
|
const store = createServerSession(client)
|
||||||
const loading = store.sync("child")
|
const loading = store.sync("child")
|
||||||
await client.rootRequested(1)
|
await client.rootRequested(1)
|
||||||
@@ -407,7 +470,7 @@ describe("server session", () => {
|
|||||||
|
|
||||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
|
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
|
||||||
expect(store.data.message.child).toEqual([assistant])
|
expect(store.data.message.child).toEqual([assistant])
|
||||||
expect(store.history.more("child")).toBe(true)
|
expect(store.history.more("child")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
|
test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
|
||||||
@@ -421,7 +484,7 @@ describe("server session", () => {
|
|||||||
{ info: parent, parts: [part] },
|
{ info: parent, parts: [part] },
|
||||||
{ info: assistant, parts: [] },
|
{ info: assistant, parts: [] },
|
||||||
]),
|
]),
|
||||||
response([{ info: assistant, parts: [] }], "older"),
|
response([{ info: assistant, parts: [] }]),
|
||||||
],
|
],
|
||||||
[missing.promise],
|
[missing.promise],
|
||||||
)
|
)
|
||||||
@@ -443,10 +506,7 @@ describe("server session", () => {
|
|||||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||||
const client = rootMessageClient(
|
const client = rootMessageClient(
|
||||||
[
|
[
|
||||||
response(
|
response(assistants.map((info) => ({ info, parts: [] }))),
|
||||||
assistants.map((info) => ({ info, parts: [] })),
|
|
||||||
"older",
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
[singleResponse(user)],
|
[singleResponse(user)],
|
||||||
)
|
)
|
||||||
@@ -468,10 +528,7 @@ describe("server session", () => {
|
|||||||
const client = rootMessageClient(
|
const client = rootMessageClient(
|
||||||
[
|
[
|
||||||
response([{ info: unrelated, parts: [] }]),
|
response([{ info: unrelated, parts: [] }]),
|
||||||
response(
|
response(assistants.map((info) => ({ info, parts: [] }))),
|
||||||
assistants.map((info) => ({ info, parts: [] })),
|
|
||||||
"older",
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
[singleResponse(user)],
|
[singleResponse(user)],
|
||||||
)
|
)
|
||||||
@@ -490,7 +547,7 @@ describe("server session", () => {
|
|||||||
const cached = userMessage("message-3", { time: { created: 3 } })
|
const cached = userMessage("message-3", { time: { created: 3 } })
|
||||||
const assistant = assistantMessage("message-4", user.id)
|
const assistant = assistantMessage("message-4", user.id)
|
||||||
const client = rootMessageClient(
|
const client = rootMessageClient(
|
||||||
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }], "older")],
|
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }])],
|
||||||
[singleResponse(user)],
|
[singleResponse(user)],
|
||||||
)
|
)
|
||||||
const store = createServerSession(client)
|
const store = createServerSession(client)
|
||||||
@@ -508,7 +565,7 @@ describe("server session", () => {
|
|||||||
const freshPart = { ...stalePart, text: "fresh" }
|
const freshPart = { ...stalePart, text: "fresh" }
|
||||||
const assistant = assistantMessage("message-2", stale.id)
|
const assistant = assistantMessage("message-2", stale.id)
|
||||||
const client = rootMessageClient(
|
const client = rootMessageClient(
|
||||||
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }], "older")],
|
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }])],
|
||||||
[singleResponse(fresh, [freshPart])],
|
[singleResponse(fresh, [freshPart])],
|
||||||
)
|
)
|
||||||
const store = createServerSession(client)
|
const store = createServerSession(client)
|
||||||
@@ -529,7 +586,7 @@ describe("server session", () => {
|
|||||||
const pending = textPart(stale.id, { id: "pending", text: "pending" })
|
const pending = textPart(stale.id, { id: "pending", text: "pending" })
|
||||||
const assistant = assistantMessage("message-2", stale.id)
|
const assistant = assistantMessage("message-2", stale.id)
|
||||||
const client = rootMessageClient(
|
const client = rootMessageClient(
|
||||||
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }], "older")],
|
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])],
|
||||||
[singleResponse(fresh, [refreshed])],
|
[singleResponse(fresh, [refreshed])],
|
||||||
)
|
)
|
||||||
const store = createServerSession(client)
|
const store = createServerSession(client)
|
||||||
@@ -552,7 +609,7 @@ describe("server session", () => {
|
|||||||
const loading = store.sync("child")
|
const loading = store.sync("child")
|
||||||
|
|
||||||
store.apply({ type: "message.updated", properties: { info: user } })
|
store.apply({ type: "message.updated", properties: { info: user } })
|
||||||
pending.resolve(response([{ info: assistant, parts: [] }], "older"))
|
pending.resolve(response([{ info: assistant, parts: [] }]))
|
||||||
await loading
|
await loading
|
||||||
|
|
||||||
expect(client.rootRequests).toEqual([])
|
expect(client.rootRequests).toEqual([])
|
||||||
@@ -568,7 +625,6 @@ describe("server session", () => {
|
|||||||
[
|
[
|
||||||
response(
|
response(
|
||||||
assistants.map((info) => ({ info, parts: [] })),
|
assistants.map((info) => ({ info, parts: [] })),
|
||||||
"older",
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||||
@@ -592,7 +648,7 @@ describe("server session", () => {
|
|||||||
const assistant = assistantMessage("message-2", user.id)
|
const assistant = assistantMessage("message-2", user.id)
|
||||||
const live = { ...assistant, cost: 1 }
|
const live = { ...assistant, cost: 1 }
|
||||||
const client = rootMessageClient(
|
const client = rootMessageClient(
|
||||||
[response([{ info: assistant, parts: [] }], "older")],
|
[response([{ info: assistant, parts: [] }])],
|
||||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||||
)
|
)
|
||||||
const store = createServerSession(client, { retry: retryImmediately })
|
const store = createServerSession(client, { retry: retryImmediately })
|
||||||
@@ -612,7 +668,7 @@ describe("server session", () => {
|
|||||||
const assistant = assistantMessage("message-2", user.id)
|
const assistant = assistantMessage("message-2", user.id)
|
||||||
const live = userMessage("message-4", { time: { created: 4 } })
|
const live = userMessage("message-4", { time: { created: 4 } })
|
||||||
const client = rootMessageClient(
|
const client = rootMessageClient(
|
||||||
[response([{ info: assistant, parts: [] }], "older")],
|
[response([{ info: assistant, parts: [] }])],
|
||||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||||
)
|
)
|
||||||
const store = createServerSession(client, { retry: retryImmediately })
|
const store = createServerSession(client, { retry: retryImmediately })
|
||||||
@@ -633,7 +689,7 @@ describe("server session", () => {
|
|||||||
const stale = textPart(assistant.id, { text: "stale" })
|
const stale = textPart(assistant.id, { text: "stale" })
|
||||||
const live = { ...stale, text: "live" }
|
const live = { ...stale, text: "live" }
|
||||||
const client = rootMessageClient(
|
const client = rootMessageClient(
|
||||||
[response([{ info: assistant, parts: [stale] }], "older")],
|
[response([{ info: assistant, parts: [stale] }])],
|
||||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||||
)
|
)
|
||||||
const store = createServerSession(client, { retry: retryImmediately })
|
const store = createServerSession(client, { retry: retryImmediately })
|
||||||
@@ -646,6 +702,7 @@ describe("server session", () => {
|
|||||||
|
|
||||||
expect(store.data.part[assistant.id]).toEqual([live])
|
expect(store.data.part[assistant.id]).toEqual([live])
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("merges live events into the initial page", async () => {
|
test("merges live events into the initial page", async () => {
|
||||||
const pending = deferredResponse()
|
const pending = deferredResponse()
|
||||||
@@ -1445,6 +1502,7 @@ describe("server session", () => {
|
|||||||
|
|
||||||
await store.history.loadMore("child")
|
await store.history.loadMore("child")
|
||||||
|
|
||||||
|
guard.active = false
|
||||||
expect(store.data.message.child).toEqual([older, latest])
|
expect(store.data.message.child).toEqual([older, latest])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,9 @@ import type {
|
|||||||
SessionStatus,
|
SessionStatus,
|
||||||
Todo,
|
Todo,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import { batch } from "solid-js"
|
import { batch } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { message as cleanMessage } from "@/utils/diffs"
|
|
||||||
import { sessionNotFoundError } from "@/utils/server-errors"
|
|
||||||
import { rootSession } from "@/utils/session-route"
|
import { rootSession } from "@/utils/session-route"
|
||||||
import { normalizeSessionInfo } from "@/utils/session"
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
import { normalizeSessionMessages } from "@/utils/session-message"
|
import { normalizeSessionMessages } from "@/utils/session-message"
|
||||||
@@ -33,6 +30,29 @@ const historyMessagePageSize = 200
|
|||||||
const sessionInfoLimit = 2_048
|
const sessionInfoLimit = 2_048
|
||||||
const emptyIDs: ReadonlySet<string> = new Set()
|
const emptyIDs: ReadonlySet<string> = new Set()
|
||||||
|
|
||||||
|
function projectMessageSource(message: Message): SessionMessageInfo[] {
|
||||||
|
if (message.role === "user") {
|
||||||
|
return [
|
||||||
|
{ id: `${message.id}:agent`, type: "agent-switched", agent: message.agent, time: message.time },
|
||||||
|
{
|
||||||
|
id: `${message.id}:model`,
|
||||||
|
type: "model-switched",
|
||||||
|
model: { id: message.model.modelID, providerID: message.model.providerID, variant: message.model.variant },
|
||||||
|
time: message.time,
|
||||||
|
},
|
||||||
|
{ id: message.id, type: "user", text: "", time: message.time },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [{
|
||||||
|
id: message.id,
|
||||||
|
type: "assistant",
|
||||||
|
agent: message.agent ?? message.mode,
|
||||||
|
model: { id: message.modelID, providerID: message.providerID, variant: message.variant },
|
||||||
|
content: [],
|
||||||
|
time: message.time,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
||||||
const boundary = source.find(
|
const boundary = source.find(
|
||||||
(message) =>
|
(message) =>
|
||||||
@@ -61,30 +81,6 @@ type MessagePage = {
|
|||||||
complete: boolean
|
complete: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
|
|
||||||
return items
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => cmp(a.info.id, b.info.id))
|
|
||||||
.map((item) => {
|
|
||||||
if (item.info.role === "user") {
|
|
||||||
return {
|
|
||||||
id: item.info.id,
|
|
||||||
type: "user" as const,
|
|
||||||
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
|
||||||
time: item.info.time,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: item.info.id,
|
|
||||||
type: "assistant" as const,
|
|
||||||
agent: item.info.agent ?? item.info.mode,
|
|
||||||
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
|
|
||||||
content: [],
|
|
||||||
time: item.info.time,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
|
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
|
||||||
type MessageLoadState = {
|
type MessageLoadState = {
|
||||||
touchedMessages: Set<string>
|
touchedMessages: Set<string>
|
||||||
@@ -183,16 +179,18 @@ function reconcileFetched<T extends { id: string }>(
|
|||||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
|
type ServerSessionOptions = { retry?: typeof retry }
|
||||||
|
type ServerSessionApis = { session: SessionApi; message: MessageApi }
|
||||||
|
|
||||||
export function createServerSession(
|
export function createServerSession(
|
||||||
client: OpencodeClient,
|
api: SessionApi | ServerSessionApis,
|
||||||
sessionApiOrOptions?: SessionApi | ServerSessionOptions,
|
messageApiOrOptions?: MessageApi | ServerSessionOptions,
|
||||||
messageApi?: MessageApi,
|
|
||||||
currentOptions?: ServerSessionOptions,
|
currentOptions?: ServerSessionOptions,
|
||||||
) {
|
) {
|
||||||
const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined
|
const bundled = "session" in api
|
||||||
const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined)
|
const sessionApi = bundled ? api.session : api
|
||||||
|
const messageApi = bundled ? api.message : (messageApiOrOptions as MessageApi)
|
||||||
|
const options = bundled ? (messageApiOrOptions as ServerSessionOptions | undefined) : currentOptions
|
||||||
const [data, setData] = createStore({
|
const [data, setData] = createStore({
|
||||||
info: {} as Record<string, Session | undefined>,
|
info: {} as Record<string, Session | undefined>,
|
||||||
session_status: {} as Record<string, SessionStatus>,
|
session_status: {} as Record<string, SessionStatus>,
|
||||||
@@ -247,13 +245,13 @@ export function createServerSession(
|
|||||||
at: {} as Record<string, number | undefined>,
|
at: {} as Record<string, number | undefined>,
|
||||||
})
|
})
|
||||||
|
|
||||||
const indexLegacyMessage = (message: Message) => {
|
const indexProjectedMessage = (message: Message) => {
|
||||||
const current = data.session_message[message.sessionID] ?? []
|
const current = data.session_message[message.sessionID] ?? []
|
||||||
if (current.some((item) => item.id === message.id)) return
|
if (current.some((item) => item.id === message.id)) return
|
||||||
setData(
|
setData(
|
||||||
"session_message",
|
"session_message",
|
||||||
message.sessionID,
|
message.sessionID,
|
||||||
reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]),
|
reconcile([...current, ...projectMessageSource(message)]),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,12 +305,7 @@ export function createServerSession(
|
|||||||
const pending = requests.get(sessionID)
|
const pending = requests.get(sessionID)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
const active = generation(sessionID)
|
const active = generation(sessionID)
|
||||||
const request = sessionApi
|
const request = sessionApi.get({ sessionID }).then(normalizeSessionInfo)
|
||||||
? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
|
|
||||||
: client.session.get({ sessionID }).then((result) => {
|
|
||||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
|
||||||
return result.data
|
|
||||||
})
|
|
||||||
const resolved = request.then((result) => {
|
const resolved = request.then((result) => {
|
||||||
if (generations.get(sessionID) !== active) return result
|
if (generations.get(sessionID) !== active) return result
|
||||||
return remember(result)
|
return remember(result)
|
||||||
@@ -538,72 +531,43 @@ export function createServerSession(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||||
if (messageApi && (await options?.protocol) !== "v1") {
|
const request = (cursor?: string) =>
|
||||||
const request = (cursor?: string) =>
|
(options?.retry ?? retry)(() => {
|
||||||
(options?.retry ?? retry)(() => {
|
onAttempt?.()
|
||||||
onAttempt?.()
|
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
|
||||||
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
|
})
|
||||||
})
|
const first = await request(before)
|
||||||
const first = await request(before)
|
const pages = [first]
|
||||||
const pages = [first]
|
while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) {
|
||||||
while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) {
|
const response = await request(pages.at(-1)!.cursor.next ?? undefined)
|
||||||
const response = await request(pages.at(-1)!.cursor.next ?? undefined)
|
pages.push(response)
|
||||||
pages.push(response)
|
if (!response.data.length) break
|
||||||
if (!response.data.length) break
|
|
||||||
}
|
|
||||||
const response = pages.at(-1)!
|
|
||||||
const source = pages.flatMap((page) => page.data).toReversed()
|
|
||||||
const normalized = normalizeSessionMessages(sessionID, source)
|
|
||||||
return {
|
|
||||||
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
|
|
||||||
part: [...normalized.parts.entries()]
|
|
||||||
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
|
||||||
.sort((a, b) => cmp(a.id, b.id)),
|
|
||||||
source,
|
|
||||||
sourceMode: before ? ("older" as const) : ("latest" as const),
|
|
||||||
projectSource: true,
|
|
||||||
cursor: response.cursor.next ?? undefined,
|
|
||||||
complete: !response.cursor.next,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const response = await (options?.retry ?? retry)(() => {
|
const response = pages.at(-1)!
|
||||||
onAttempt?.()
|
const source = pages.flatMap((page) => page.data).toReversed()
|
||||||
return client.session.messages({ sessionID, limit, before })
|
const normalized = normalizeSessionMessages(sessionID, source)
|
||||||
})
|
|
||||||
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
|
||||||
return {
|
return {
|
||||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
|
||||||
part: items.map((item) => ({
|
part: [...normalized.parts.entries()]
|
||||||
id: item.info.id,
|
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
||||||
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
.sort((a, b) => cmp(a.id, b.id)),
|
||||||
})),
|
source,
|
||||||
source: legacyMessageSource(items),
|
|
||||||
sourceMode: before ? ("older" as const) : ("latest" as const),
|
sourceMode: before ? ("older" as const) : ("latest" as const),
|
||||||
cursor: response.response.headers.get("x-next-cursor") ?? undefined,
|
projectSource: true,
|
||||||
complete: !response.response.headers.get("x-next-cursor"),
|
cursor: response.cursor.next ?? undefined,
|
||||||
|
complete: !response.cursor.next,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
|
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
|
||||||
if (sessionApi && (await options?.protocol) !== "v1") {
|
|
||||||
const response = await (options?.retry ?? retry)(() => {
|
|
||||||
onAttempt?.()
|
|
||||||
return sessionApi.message({ sessionID, messageID })
|
|
||||||
})
|
|
||||||
const normalized = normalizeSessionMessages(sessionID, [response])
|
|
||||||
const message = normalized.messages[0]
|
|
||||||
if (!message) throw new Error(`Message not found: ${messageID}`)
|
|
||||||
return { message, parts: normalized.parts.get(messageID) ?? [] }
|
|
||||||
}
|
|
||||||
const response = await (options?.retry ?? retry)(() => {
|
const response = await (options?.retry ?? retry)(() => {
|
||||||
onAttempt?.()
|
onAttempt?.()
|
||||||
return client.session.message({ sessionID, messageID })
|
return sessionApi.message({ sessionID, messageID })
|
||||||
})
|
})
|
||||||
if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`)
|
const normalized = normalizeSessionMessages(sessionID, [response])
|
||||||
return {
|
const message = normalized.messages[0]
|
||||||
message: cleanMessage(response.data.info),
|
if (!message) throw new Error(`Message not found: ${messageID}`)
|
||||||
parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
return { message, parts: normalized.parts.get(messageID) ?? [] }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const replaceMessages = (sessionID: string, messages: Message[]) => {
|
const replaceMessages = (sessionID: string, messages: Message[]) => {
|
||||||
@@ -1036,8 +1000,8 @@ export function createServerSession(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "message.updated": {
|
case "message.updated": {
|
||||||
const info = cleanMessage((event.properties as { info: Message }).info)
|
const info = (event.properties as { info: Message }).info
|
||||||
indexLegacyMessage(info)
|
indexProjectedMessage(info)
|
||||||
const load = messageLoads.get(info.sessionID)
|
const load = messageLoads.get(info.sessionID)
|
||||||
load?.touchedMessages.add(info.id)
|
load?.touchedMessages.add(info.id)
|
||||||
load?.removedMessages.delete(info.id)
|
load?.removedMessages.delete(info.id)
|
||||||
@@ -1388,17 +1352,8 @@ export function createServerSession(
|
|||||||
async todo(sessionID: string, request?: { force?: boolean }) {
|
async todo(sessionID: string, request?: { force?: boolean }) {
|
||||||
touch(sessionID)
|
touch(sessionID)
|
||||||
if (data.todo[sessionID] !== undefined && !request?.force) return
|
if (data.todo[sessionID] !== undefined && !request?.force) return
|
||||||
if ((await options?.protocol) === "v2") {
|
// TODO: Restore todos when the V2 client exposes a session todo API.
|
||||||
setData("todo", sessionID, [])
|
setData("todo", sessionID, [])
|
||||||
return
|
|
||||||
}
|
|
||||||
return runInflight(inflightTodo, sessionID, () => {
|
|
||||||
const active = generation(sessionID)
|
|
||||||
return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => {
|
|
||||||
if (generations.get(sessionID) !== active) return
|
|
||||||
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
history: {
|
history: {
|
||||||
more: (sessionID: string) =>
|
more: (sessionID: string) =>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import type {
|
import type {
|
||||||
McpListInput,
|
McpListInput,
|
||||||
McpResourceCatalogInput,
|
McpResourceCatalogInput,
|
||||||
@@ -84,7 +83,7 @@ describe("active session query", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("does not overwrite statuses already written by events", () => {
|
test("does not overwrite statuses already written by events", () => {
|
||||||
const session = createServerSession({} as OpencodeClient)
|
const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"])
|
||||||
session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 })
|
session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 })
|
||||||
|
|
||||||
seedActiveSessionStatuses(session, {
|
seedActiveSessionStatuses(session, {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type {
|
|||||||
ProviderAuthResponse,
|
ProviderAuthResponse,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||||
@@ -132,10 +131,11 @@ export const loadMcpResourcesQuery = (
|
|||||||
placeholderData: {},
|
placeholderData: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
export const loadLspQuery = (scope: ServerScope, directory: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "lsp"] as const,
|
queryKey: [scope, directory, "lsp"] as const,
|
||||||
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
// TODO: Restore LSP status when the V2 client exposes an LSP API.
|
||||||
|
queryFn: async () => [],
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadActiveSessionsQuery = (
|
export const loadActiveSessionsQuery = (
|
||||||
@@ -166,22 +166,19 @@ export function seedActiveSessionStatuses(
|
|||||||
|
|
||||||
function makeQueryOptionsApi(
|
function makeQueryOptionsApi(
|
||||||
scope: ServerScope,
|
scope: ServerScope,
|
||||||
serverSDK: () => OpencodeClient,
|
|
||||||
serverAPI: ServerApi,
|
serverAPI: ServerApi,
|
||||||
sdkFor: (dir: PathKey) => OpencodeClient,
|
|
||||||
protocol: Promise<"v1" | "v2">,
|
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
globalConfig: () => loadGlobalConfigQuery(scope),
|
||||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||||
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
providers: (directory: PathKey | null) =>
|
||||||
path: (directory: PathKey | null) =>
|
loadProvidersQuery(scope, directory, serverAPI),
|
||||||
loadPathQuery(scope, directory, serverAPI.location, directory ? sdkFor(directory) : serverSDK(), protocol),
|
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
||||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
|
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
|
||||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
|
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
|
||||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
|
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
|
||||||
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
|
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
|
||||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
|
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
|
||||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,31 +190,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
if (!owner) throw new Error("ServerSync must be created within owner")
|
if (!owner) throw new Error("ServerSync must be created within owner")
|
||||||
|
|
||||||
const sdkCache = new Map<string, OpencodeClient>()
|
|
||||||
const booting = new Map<string, Promise<void>>()
|
const booting = new Map<string, Promise<void>>()
|
||||||
const sessionLoads = new Map<string, Promise<void>>()
|
const sessionLoads = new Map<string, Promise<void>>()
|
||||||
const sessionMeta = new Map<string, { limit: number }>()
|
const sessionMeta = new Map<string, { limit: number }>()
|
||||||
|
|
||||||
const sdkFor = (directory: string) => {
|
const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
|
||||||
const key = directoryKey(directory)
|
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
|
||||||
const cached = sdkCache.get(key)
|
|
||||||
if (cached) return cached
|
|
||||||
const sdk = serverSDK.createClient({
|
|
||||||
directory,
|
|
||||||
throwOnError: true,
|
|
||||||
})
|
|
||||||
sdkCache.set(key, sdk)
|
|
||||||
return sdk
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = createServerSession(serverSDK.client, serverSDK.currentApi.session, serverSDK.currentApi.message)
|
|
||||||
const queryOptionsApi = makeQueryOptionsApi(
|
|
||||||
serverSDK.scope,
|
|
||||||
() => serverSDK.client,
|
|
||||||
serverSDK.currentApi,
|
|
||||||
sdkFor,
|
|
||||||
serverSDK.protocol,
|
|
||||||
)
|
|
||||||
|
|
||||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||||
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
||||||
@@ -225,7 +203,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
const activeSessionsQuery = useQuery(() =>
|
const activeSessionsQuery = useQuery(() =>
|
||||||
loadActiveSessionsQuery(serverSDK.scope, {
|
loadActiveSessionsQuery(serverSDK.scope, {
|
||||||
active: async () => {
|
active: async () => {
|
||||||
const active = await serverSDK.currentApi.session.active()
|
const active = await serverSDK.api.session.active()
|
||||||
seedActiveSessionStatuses(session, active)
|
seedActiveSessionStatuses(session, active)
|
||||||
for (const sessionID of Object.keys(active)) {
|
for (const sessionID of Object.keys(active)) {
|
||||||
void session.resolve(sessionID).catch(() => undefined)
|
void session.resolve(sessionID).catch(() => undefined)
|
||||||
@@ -293,9 +271,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
queryKey: [serverSDK.scope, "bootstrap"],
|
queryKey: [serverSDK.scope, "bootstrap"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
await bootstrapGlobal({
|
await bootstrapGlobal({
|
||||||
serverSDK: serverSDK.client,
|
serverAPI: serverSDK.api,
|
||||||
serverAPI: serverSDK.currentApi,
|
|
||||||
protocol: serverSDK.protocol,
|
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
requestFailedTitle: language.t("common.requestFailed"),
|
requestFailedTitle: language.t("common.requestFailed"),
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
@@ -335,7 +311,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
void bootstrapInstance(directory)
|
void bootstrapInstance(directory)
|
||||||
},
|
},
|
||||||
onMcp: (directory, setStore) => {
|
onMcp: (directory, setStore) => {
|
||||||
void loadCommands(directory, serverSDK.currentApi.command)
|
void loadCommands(directory, serverSDK.api.command)
|
||||||
.then((commands) => setStore("command", commands))
|
.then((commands) => setStore("command", commands))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -349,7 +325,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
queue.clear(key)
|
queue.clear(key)
|
||||||
sessionMeta.delete(key)
|
sessionMeta.delete(key)
|
||||||
sdkCache.delete(key)
|
|
||||||
clearProviderRev(serverSDK.scope, key)
|
clearProviderRev(serverSDK.scope, key)
|
||||||
},
|
},
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
@@ -388,7 +363,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
.fetchQuery({
|
.fetchQuery({
|
||||||
...queryOptionsApi.sessions(key),
|
...queryOptionsApi.sessions(key),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
loadRootSessions({ api: serverSDK.currentApi.session, directory, limit })
|
loadRootSessions({ api: serverSDK.api.session, directory, limit })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
const nonArchived = (x.data ?? [])
|
const nonArchived = (x.data ?? [])
|
||||||
.filter((s) => !!s?.id)
|
.filter((s) => !!s?.id)
|
||||||
@@ -446,7 +421,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
const child = children.ensureChild(directory)
|
const child = children.ensureChild(directory)
|
||||||
const cache = children.vcsCache.get(key)
|
const cache = children.vcsCache.get(key)
|
||||||
if (!cache) return
|
if (!cache) return
|
||||||
const sdk = sdkFor(directory)
|
|
||||||
await bootstrapDirectory({
|
await bootstrapDirectory({
|
||||||
directory,
|
directory,
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
@@ -457,8 +431,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
project: globalStore.project,
|
project: globalStore.project,
|
||||||
provider: globalStore.provider,
|
provider: globalStore.provider,
|
||||||
},
|
},
|
||||||
sdk,
|
api: serverSDK.api,
|
||||||
api: serverSDK.currentApi,
|
|
||||||
store: child[0],
|
store: child[0],
|
||||||
setStore: child[1],
|
setStore: child[1],
|
||||||
vcsCache: cache,
|
vcsCache: cache,
|
||||||
@@ -466,7 +439,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
translate: language.t,
|
translate: language.t,
|
||||||
queryClient,
|
queryClient,
|
||||||
session,
|
session,
|
||||||
protocol: serverSDK.protocol,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -505,7 +477,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
if (event.current) session.applyV2(event.current)
|
if (event.current) session.applyV2(event.current)
|
||||||
session.apply(event)
|
session.apply(event)
|
||||||
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
||||||
homeSessions.apply(event)
|
if ("info" in event.properties) homeSessions.apply(event as Parameters<typeof homeSessions.apply>[0])
|
||||||
}
|
}
|
||||||
homeSessions.refresh(event.type)
|
homeSessions.refresh(event.type)
|
||||||
if (eventType === "integration.connection.updated") void refreshProviders()
|
if (eventType === "integration.connection.updated") void refreshProviders()
|
||||||
@@ -625,7 +597,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateConfigMutation = useMutation(() => ({
|
const updateConfigMutation = useMutation(() => ({
|
||||||
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
|
mutationFn: async (config: Config) => {
|
||||||
|
// TODO: Restore config updates when the V2 client exposes a config API.
|
||||||
|
// await serverSDK.api.config.update({ config })
|
||||||
|
throw new Error(`Config updates are unavailable: ${Object.keys(config).length} fields were not saved`)
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
bootstrap.refetch()
|
bootstrap.refetch()
|
||||||
// Invalidate all provider queries so newly configured custom providers
|
// Invalidate all provider queries so newly configured custom providers
|
||||||
@@ -664,24 +640,24 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
await toggleMcp({
|
await toggleMcp({
|
||||||
status,
|
status,
|
||||||
connect: async () => {
|
connect: async () => {
|
||||||
await serverSDK.currentApi.mcp.connect({ server: name, location: { directory: key } })
|
await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
|
||||||
},
|
},
|
||||||
disconnect: async () => {
|
disconnect: async () => {
|
||||||
await serverSDK.currentApi.mcp.disconnect({ server: name, location: { directory: key } })
|
await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
|
||||||
},
|
},
|
||||||
authenticate: async () => {
|
authenticate: async () => {
|
||||||
const server = (await serverSDK.currentApi.mcp.list({ location: { directory: key } })).data.find(
|
const server = (await serverSDK.api.mcp.list({ location: { directory: key } })).data.find(
|
||||||
(item) => item.name === name,
|
(item) => item.name === name,
|
||||||
)
|
)
|
||||||
if (!server?.integrationID) throw new Error(`MCP server ${name} has no authentication integration`)
|
if (!server?.integrationID) throw new Error(`MCP server ${name} has no authentication integration`)
|
||||||
const integration = await serverSDK.currentApi.integration.get({
|
const integration = await serverSDK.api.integration.get({
|
||||||
integrationID: server.integrationID,
|
integrationID: server.integrationID,
|
||||||
location: { directory: key },
|
location: { directory: key },
|
||||||
})
|
})
|
||||||
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.prompts?.length)
|
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.prompts?.length)
|
||||||
if (!method || method.type !== "oauth")
|
if (!method || method.type !== "oauth")
|
||||||
throw new Error(`MCP server ${name} requires an interactive authentication form`)
|
throw new Error(`MCP server ${name} requires an interactive authentication form`)
|
||||||
const attempt = await serverSDK.currentApi.integration.oauth.connect({
|
const attempt = await serverSDK.api.integration.oauth.connect({
|
||||||
integrationID: server.integrationID,
|
integrationID: server.integrationID,
|
||||||
methodID: method.id,
|
methodID: method.id,
|
||||||
inputs: {},
|
inputs: {},
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ function createWorkspaceTerminalSession(
|
|||||||
setStore("all", index, (item) => ({ ...item, ...pty }))
|
setStore("all", index, (item) => ({ ...item, ...pty }))
|
||||||
}
|
}
|
||||||
const doUpdate = async () => {
|
const doUpdate = async () => {
|
||||||
await sdk.currentApi.pty.update({
|
await sdk.api.pty.update({
|
||||||
ptyID: pty.id,
|
ptyID: pty.id,
|
||||||
location,
|
location,
|
||||||
title: pty.title,
|
title: pty.title,
|
||||||
@@ -268,13 +268,10 @@ function createWorkspaceTerminalSession(
|
|||||||
const index = store.all.findIndex((x) => x.id === id)
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
const pty = store.all[index]
|
const pty = store.all[index]
|
||||||
if (!pty) return
|
if (!pty) return
|
||||||
const data = await sdk.currentApi.pty
|
const data = await sdk.api.pty.create({ location, title: pty.title }).then((result) => result.data).catch((error: unknown) => {
|
||||||
.create({ location, title: pty.title })
|
console.error("Failed to clone terminal", error)
|
||||||
.then((result) => result.data)
|
return undefined
|
||||||
.catch((error: unknown) => {
|
})
|
||||||
console.error("Failed to clone terminal", error)
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
if (!data?.id) return
|
if (!data?.id) return
|
||||||
|
|
||||||
const active = store.active === pty.id
|
const active = store.active === pty.id
|
||||||
@@ -311,9 +308,7 @@ function createWorkspaceTerminalSession(
|
|||||||
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
|
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
|
||||||
|
|
||||||
const doCreate = async () => {
|
const doCreate = async () => {
|
||||||
return sdk.currentApi.pty
|
return sdk.api.pty.create({ location, title: defaultTitle(nextNumber) }).then((result) => result.data)
|
||||||
.create({ location, title: defaultTitle(nextNumber) })
|
|
||||||
.then((result) => result.data)
|
|
||||||
}
|
}
|
||||||
doCreate()
|
doCreate()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -417,7 +412,7 @@ function createWorkspaceTerminalSession(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await sdk.currentApi.pty.remove({ ptyID: id, location }).catch((error: unknown) => {
|
await sdk.api.pty.remove({ ptyID: id, location }).catch((error: unknown) => {
|
||||||
console.error("Failed to close terminal", error)
|
console.error("Failed to close terminal", error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { serverName } from "@/context/server"
|
import { serverName } from "@/context/server"
|
||||||
import { displayName } from "@/pages/layout/helpers"
|
import { displayName } from "@/pages/layout/helpers"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
|
||||||
import { createMemo, onCleanup } from "solid-js"
|
import { createMemo, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { HomeController } from "./home-controller"
|
import type { HomeController } from "./home-controller"
|
||||||
@@ -24,7 +23,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
|||||||
if (!value) return []
|
if (!value) return []
|
||||||
return sessions.data
|
return sessions.data
|
||||||
.searchRecords()
|
.searchRecords()
|
||||||
.filter((record) => `${displayLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
|
.filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
|
||||||
})
|
})
|
||||||
const active = createMemo(() => {
|
const active = createMemo(() => {
|
||||||
const records = results()
|
const records = results()
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function createHomeSessionsController(home: HomeController) {
|
|||||||
const cache = homeSessions()
|
const cache = homeSessions()
|
||||||
const eventSequence = cache.eventSequence()
|
const eventSequence = cache.eventSequence()
|
||||||
const index = await loadHomeSessionIndex(
|
const index = await loadHomeSessionIndex(
|
||||||
(input, options) => ctx.sdk.client.v2.session.list(input, options),
|
(input, options) => ctx.sdk.api.session.list(input, options),
|
||||||
eventSequence,
|
eventSequence,
|
||||||
signal,
|
signal,
|
||||||
)
|
)
|
||||||
@@ -211,16 +211,11 @@ export function createHomeSessionsController(home: HomeController) {
|
|||||||
const ctx = home.server.focusedContext()
|
const ctx = home.server.focusedContext()
|
||||||
if (!conn || !ctx) return
|
if (!conn || !ctx) return
|
||||||
const [, setStore] = ctx.sync.child(session.directory)
|
const [, setStore] = ctx.sync.child(session.directory)
|
||||||
if ((await ctx.sdk.protocol) !== "v1") return
|
|
||||||
await archiveHomeSession({
|
await archiveHomeSession({
|
||||||
server: ServerConnection.key(conn),
|
server: ServerConnection.key(conn),
|
||||||
session,
|
session,
|
||||||
archive: (sessionID) =>
|
// TODO: Restore archiving when the V2 client exposes a session archive API.
|
||||||
ctx.sdk.client.session.update({
|
archive: async (_sessionID) => Promise.reject(new Error("Session archiving is unavailable")),
|
||||||
sessionID,
|
|
||||||
directory: session.directory,
|
|
||||||
time: { archived: Date.now() },
|
|
||||||
}),
|
|
||||||
remove: () =>
|
remove: () =>
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
|||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
|
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
|
||||||
|
import { sessionTitle } from "@/utils/session-title"
|
||||||
import { shouldOpenSessionInBackground } from "../home-session-open"
|
import { shouldOpenSessionInBackground } from "../home-session-open"
|
||||||
import {
|
import {
|
||||||
HomeSessionStatusController,
|
HomeSessionStatusController,
|
||||||
@@ -344,7 +344,7 @@ function HomeSessionSearchResultRow(
|
|||||||
selected: boolean
|
selected: boolean
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const title = createMemo(() => displayLabel(props.record.session))
|
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||||
const key = () => homeSessionSearchKey(props.record)
|
const key = () => homeSessionSearchKey(props.record)
|
||||||
|
|
||||||
@@ -415,7 +415,7 @@ function HomeSessionGroupHeader(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
||||||
const title = createMemo(() => displayLabel(props.record.session))
|
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -390,22 +390,6 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unsub = serverSDK().event.listen((e) => {
|
const unsub = serverSDK().event.listen((e) => {
|
||||||
if (e.details?.type === "worktree.ready") {
|
|
||||||
setBusy(e.name, false)
|
|
||||||
WorktreeState.ready(serverSDK().scope, e.name)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.details?.type === "worktree.failed") {
|
|
||||||
setBusy(e.name, false)
|
|
||||||
WorktreeState.failed(
|
|
||||||
serverSDK().scope,
|
|
||||||
e.name,
|
|
||||||
e.details.properties?.message ?? language.t("common.requestFailed"),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
e.details?.type === "question.replied" ||
|
e.details?.type === "question.replied" ||
|
||||||
e.details?.type === "question.rejected" ||
|
e.details?.type === "question.rejected" ||
|
||||||
@@ -872,17 +856,14 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function archiveSession(session: Session) {
|
async function archiveSession(session: Session) {
|
||||||
if ((await serverSDK().protocol) !== "v1") return
|
// TODO: Restore archiving when the V2 client exposes a session archive API.
|
||||||
|
void session
|
||||||
|
return
|
||||||
const [store, setStore] = serverSync().child(session.directory)
|
const [store, setStore] = serverSync().child(session.directory)
|
||||||
const sessions = store.session ?? []
|
const sessions = store.session ?? []
|
||||||
const index = sessions.findIndex((s) => s.id === session.id)
|
const index = sessions.findIndex((s) => s.id === session.id)
|
||||||
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
||||||
|
|
||||||
await serverSDK().client.session.update({
|
|
||||||
sessionID: session.id,
|
|
||||||
directory: session.directory,
|
|
||||||
time: { archived: Date.now() },
|
|
||||||
})
|
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const match = Binary.search(draft.session, session.id, (s) => s.id)
|
const match = Binary.search(draft.session, session.id, (s) => s.id)
|
||||||
@@ -980,7 +961,8 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
title: language.t("command.session.archive"),
|
title: language.t("command.session.archive"),
|
||||||
category: language.t("command.category.session"),
|
category: language.t("command.category.session"),
|
||||||
keybind: "mod+shift+backspace",
|
keybind: "mod+shift+backspace",
|
||||||
disabled: !params.dir || !params.id,
|
// TODO: Restore the command when the V2 client exposes session archive.
|
||||||
|
disabled: true,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
const session = currentSessions().find((s) => s.id === params.id)
|
const session = currentSessions().find((s) => s.id === params.id)
|
||||||
if (session) void archiveSession(session)
|
if (session) void archiveSession(session)
|
||||||
@@ -1189,13 +1171,10 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
const refreshDirs = async (target?: string) => {
|
const refreshDirs = async (target?: string) => {
|
||||||
if (!target || target === root || canOpen(target)) return canOpen(target)
|
if (!target || target === root || canOpen(target)) return canOpen(target)
|
||||||
const listed = await Promise.resolve(
|
const listed = await Promise.resolve(
|
||||||
project?.id ?? serverSDK().currentApi.project.current({ location: { directory: root } }),
|
project?.id ?? serverSDK().api.project.current({ location: { directory: root } }),
|
||||||
)
|
)
|
||||||
.then((value) => (typeof value === "string" ? value : value.id))
|
.then((value) => (typeof value === "string" ? value : value.id))
|
||||||
.then(async (projectID) => {
|
.then((projectID) => serverSDK().api.project.directories({ projectID, location: { directory: root } }))
|
||||||
await serverSDK().currentApi.projectCopy.refresh({ projectID, location: { directory: root } })
|
|
||||||
return serverSDK().currentApi.project.directories({ projectID, location: { directory: root } })
|
|
||||||
})
|
|
||||||
.then((items) => items.map((item) => item.directory).filter((item) => pathKey(item) !== pathKey(root)))
|
.then((items) => items.map((item) => item.directory).filter((item) => pathKey(item) !== pathKey(root)))
|
||||||
.catch(() => [] as string[])
|
.catch(() => [] as string[])
|
||||||
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
|
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
|
||||||
@@ -1240,7 +1219,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
dirs.map(async (item) => ({
|
dirs.map(async (item) => ({
|
||||||
path: { directory: item },
|
path: { directory: item },
|
||||||
session: await listAllSessions(serverSDK().currentApi.session, {
|
session: await listAllSessions(serverSDK().api.session, {
|
||||||
directory: item,
|
directory: item,
|
||||||
parentID: null,
|
parentID: null,
|
||||||
order: "desc",
|
order: "desc",
|
||||||
@@ -1304,16 +1283,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
const name = next === getFilename(project.worktree) ? "" : next
|
const name = next === getFilename(project.worktree) ? "" : next
|
||||||
|
|
||||||
if (project.id && project.id !== "global") {
|
if (project.id && project.id !== "global") {
|
||||||
const sdk = serverSDK()
|
// TODO: Restore project renames when the V2 client exposes a project update API.
|
||||||
if ((await sdk.protocol) !== "v1") return
|
|
||||||
const result = await sdk.client.project
|
|
||||||
.update({ projectID: project.id, directory: project.worktree, name })
|
|
||||||
.then((response) => response.data)
|
|
||||||
if (!result) return
|
|
||||||
// const result = await serverSDK().api.project.update({ projectID: project.id, name })
|
|
||||||
serverSync().set("project", (items) =>
|
|
||||||
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1409,7 +1379,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
const projectID = serverSync().data.project.find((project) => project.worktree === root)?.id
|
const projectID = serverSync().data.project.find((project) => project.worktree === root)?.id
|
||||||
const result = projectID
|
const result = projectID
|
||||||
? await serverSDK()
|
? await serverSDK()
|
||||||
.currentApi.projectCopy.remove({ projectID, directory, force: false, location: { directory: root } })
|
.api.projectCopy.remove({ projectID, directory, force: false, location: { directory: root } })
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -1467,9 +1437,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
const dismiss = () => toaster.dismiss(progress)
|
const dismiss = () => toaster.dismiss(progress)
|
||||||
|
|
||||||
const sessions = await listAllSessions(serverSDK().currentApi.session, { directory, order: "desc" }).catch(
|
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
|
||||||
() => [],
|
|
||||||
)
|
|
||||||
|
|
||||||
clearWorkspaceTerminals(
|
clearWorkspaceTerminals(
|
||||||
directory,
|
directory,
|
||||||
@@ -1477,20 +1445,8 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
platform,
|
platform,
|
||||||
serverSDK().scope,
|
serverSDK().scope,
|
||||||
)
|
)
|
||||||
await serverSDK()
|
// TODO: Restore workspace reset and instance disposal when V2 exposes these operations.
|
||||||
.client.instance.dispose({ directory })
|
const result = false
|
||||||
.catch(() => undefined)
|
|
||||||
|
|
||||||
const result = await serverSDK()
|
|
||||||
.client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
|
|
||||||
.then((x) => x.data)
|
|
||||||
.catch((err) => {
|
|
||||||
showToast({
|
|
||||||
title: language.t("workspace.reset.failed.title"),
|
|
||||||
description: errorMessage(err, language.t("common.requestFailed")),
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
setBusy(directory, false)
|
setBusy(directory, false)
|
||||||
@@ -1498,21 +1454,6 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((await serverSDK().protocol) === "v1")
|
|
||||||
await Promise.all(
|
|
||||||
sessions
|
|
||||||
.filter((session) => session.time.archived === undefined)
|
|
||||||
.map((session) =>
|
|
||||||
serverSDK()
|
|
||||||
.client.session.update({
|
|
||||||
sessionID: session.id,
|
|
||||||
directory: session.directory,
|
|
||||||
time: { archived: Date.now() },
|
|
||||||
})
|
|
||||||
.catch(() => undefined),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
setBusy(directory, false)
|
setBusy(directory, false)
|
||||||
dismiss()
|
dismiss()
|
||||||
|
|
||||||
@@ -1603,7 +1544,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
const sessions = await listAllSessions(serverSDK().currentApi.session, {
|
const sessions = await listAllSessions(serverSDK().api.session, {
|
||||||
directory: props.directory,
|
directory: props.directory,
|
||||||
order: "desc",
|
order: "desc",
|
||||||
}).catch(() => [])
|
}).catch(() => [])
|
||||||
@@ -1845,7 +1786,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
clearSidebarHoverState()
|
clearSidebarHoverState()
|
||||||
const created = project.id
|
const created = project.id
|
||||||
? await serverSDK()
|
? await serverSDK()
|
||||||
.currentApi.projectCopy.create({
|
.api.projectCopy.create({
|
||||||
projectID: project.id,
|
projectID: project.id,
|
||||||
strategy: "git_worktree",
|
strategy: "git_worktree",
|
||||||
directory: getDirectory(project.worktree),
|
directory: getDirectory(project.worktree),
|
||||||
@@ -2031,16 +1972,21 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
<div class="shrink-0 pl-1 py-1">
|
<div class="shrink-0 pl-1 py-1">
|
||||||
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
|
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
|
||||||
<div class="flex flex-col min-w-0">
|
<div class="flex flex-col min-w-0">
|
||||||
<InlineEditor
|
<Show
|
||||||
id={`project:${projectId()}`}
|
when={!project.id || project.id === "global"}
|
||||||
value={projectName}
|
fallback={<span class="text-14-medium text-text-strong truncate">{projectName()}</span>}
|
||||||
onSave={(next) => {
|
>
|
||||||
void renameProject(project, next)
|
<InlineEditor
|
||||||
}}
|
id={`project:${projectId()}`}
|
||||||
class="text-14-medium text-text-strong truncate"
|
value={projectName}
|
||||||
displayClass="text-14-medium text-text-strong truncate"
|
onSave={(next) => {
|
||||||
stopPropagation
|
void renameProject(project, next)
|
||||||
/>
|
}}
|
||||||
|
class="text-14-medium text-text-strong truncate"
|
||||||
|
displayClass="text-14-medium text-text-strong truncate"
|
||||||
|
stopPropagation
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { A, useParams } from "@solidjs/router"
|
import { A, useParams } from "@solidjs/router"
|
||||||
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
|
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
|
||||||
@@ -15,6 +14,7 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
|||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { messageAgentColor } from "@/utils/agent"
|
import { messageAgentColor } from "@/utils/agent"
|
||||||
|
import { sessionTitle } from "@/utils/session-title"
|
||||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||||
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
|
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ const SessionRow = (props: {
|
|||||||
warmPress: () => void
|
warmPress: () => void
|
||||||
warmFocus: () => void
|
warmFocus: () => void
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const title = () => displayLabel(props.session)
|
const title = () => sessionTitle(props.session.title)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<A
|
<A
|
||||||
@@ -229,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
fallback={
|
fallback={
|
||||||
<Tooltip
|
<Tooltip
|
||||||
placement={props.mobile ? "bottom" : "right"}
|
placement={props.mobile ? "bottom" : "right"}
|
||||||
value={displayLabel(props.session)}
|
value={sessionTitle(props.session.title)}
|
||||||
gutter={10}
|
gutter={10}
|
||||||
class="min-w-0 w-full"
|
class="min-w-0 w-full"
|
||||||
>
|
>
|
||||||
@@ -241,7 +241,8 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={!props.level}>
|
{/* TODO: Restore the archive action when the V2 client exposes session archive. */}
|
||||||
|
<Show when={false}>
|
||||||
<div
|
<div
|
||||||
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
||||||
classList={{
|
classList={{
|
||||||
|
|||||||
@@ -200,8 +200,9 @@ const WorkspaceActions = (props: {
|
|||||||
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
disabled={props.local() || props.busy()}
|
// TODO: Restore reset when V2 exposes project-copy reset and instance disposal.
|
||||||
onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
|
// onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
|
||||||
|
disabled
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|||||||
@@ -847,11 +847,8 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gitMutation = useMutation(() => ({
|
const gitMutation = useMutation(() => ({
|
||||||
mutationFn: () => sdk().client.project.initGit(),
|
// TODO: Restore Git initialization when the V2 client exposes this operation.
|
||||||
onSuccess: (x) => {
|
mutationFn: async () => Promise.reject(new Error("Git initialization is unavailable")),
|
||||||
if (!x.data) return
|
|
||||||
upsert(x.data)
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
@@ -1217,11 +1214,7 @@ export default function Page() {
|
|||||||
{language.t("session.review.noVcs.createGit.description")}
|
{language.t("session.review.noVcs.createGit.description")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
|
{/* TODO: Restore the init button when the V2 client exposes Git initialization. */}
|
||||||
{gitMutation.isPending
|
|
||||||
? language.t("session.review.noVcs.createGit.actionLoading")
|
|
||||||
: language.t("session.review.noVcs.createGit.action")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1254,7 +1247,8 @@ export default function Page() {
|
|||||||
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
|
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
|
||||||
}
|
}
|
||||||
if (reviewMode() === "turn" && nogit()) {
|
if (reviewMode() === "turn" && nogit()) {
|
||||||
return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
|
// TODO: Restore SessionReviewEmptyNoGitV2 when the V2 client exposes Git initialization.
|
||||||
|
return empty(language.t("session.review.noVcs.createGit.description"))
|
||||||
}
|
}
|
||||||
return <SessionReviewEmptyChangesV2 />
|
return <SessionReviewEmptyChangesV2 />
|
||||||
}
|
}
|
||||||
@@ -1724,9 +1718,10 @@ export default function Page() {
|
|||||||
setFollowup("failed", input.sessionID, undefined)
|
setFollowup("failed", input.sessionID, undefined)
|
||||||
|
|
||||||
const ok = await sendFollowupDraft({
|
const ok = await sendFollowupDraft({
|
||||||
api: sdk().currentApi.session,
|
api: sdk().api.session,
|
||||||
sync: sync(),
|
sync: sync(),
|
||||||
serverSync: serverSync(),
|
serverSync: serverSync(),
|
||||||
|
session: () => sync().session.get(input.sessionID),
|
||||||
draft: item,
|
draft: item,
|
||||||
optimisticBusy: item.sessionDirectory === sdk().directory,
|
optimisticBusy: item.sessionDirectory === sdk().directory,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
@@ -1820,13 +1815,13 @@ export default function Page() {
|
|||||||
const halt = (sessionID: string) =>
|
const halt = (sessionID: string) =>
|
||||||
busy(sessionID)
|
busy(sessionID)
|
||||||
? sdk()
|
? sdk()
|
||||||
.currentApi.session.interrupt({ sessionID })
|
.api.session.interrupt({ sessionID })
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
: Promise.resolve()
|
: Promise.resolve()
|
||||||
|
|
||||||
const revertMutation = useMutation(() => ({
|
const revertMutation = useMutation(() => ({
|
||||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||||
const session = sdk().currentApi.session
|
const session = sdk().api.session
|
||||||
const target = sync()
|
const target = sync()
|
||||||
const last = target.session.get(input.sessionID)?.revert
|
const last = target.session.get(input.sessionID)?.revert
|
||||||
const value = draft(input.messageID)
|
const value = draft(input.messageID)
|
||||||
@@ -1849,7 +1844,7 @@ export default function Page() {
|
|||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const session = sdk().currentApi.session
|
const session = sdk().api.session
|
||||||
const target = sync()
|
const target = sync()
|
||||||
const next = userMessages().find((item) => item.id > id)
|
const next = userMessages().find((item) => item.id > id)
|
||||||
const last = target.session.get(sessionID)?.revert
|
const last = target.session.get(sessionID)?.revert
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
|||||||
|
|
||||||
const readFile = async (path: string) => {
|
const readFile = async (path: string) => {
|
||||||
return sdk()
|
return sdk()
|
||||||
.currentApi.file.read({ path, location: { directory: sdk().directory } })
|
.api.file.read({ path, location: { directory: sdk().directory } })
|
||||||
.then((content) => ({ type: "text" as const, content: new TextDecoder().decode(content) }))
|
.then((data) => ({ type: "text" as const, content: new TextDecoder().decode(data) }))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.debug("[session-review] failed to read file", { path, error })
|
console.debug("[session-review] failed to read file", { path, error })
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
import { sessionTitle } from "@/utils/session-title"
|
||||||
import { scheduleConnectedMeasure } from "./measure"
|
import { scheduleConnectedMeasure } from "./measure"
|
||||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||||
import { createTimelineProjection } from "./projection"
|
import { createTimelineProjection } from "./projection"
|
||||||
@@ -296,13 +296,12 @@ export function MessageTimeline(props: {
|
|||||||
if (!id) return
|
if (!id) return
|
||||||
return sync().session.get(id)
|
return sync().session.get(id)
|
||||||
})
|
})
|
||||||
const titleLabel = createMemo(() => {
|
const titleValue = createMemo(() => info()?.title)
|
||||||
const session = info()
|
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||||
if (!session) return
|
|
||||||
return displayLabel(session)
|
|
||||||
})
|
|
||||||
const shareUrl = createMemo(() => info()?.share?.url)
|
const shareUrl = createMemo(() => info()?.share?.url)
|
||||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
// TODO: Restore these actions when the V2 client exposes session sharing.
|
||||||
|
// const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||||
|
const shareEnabled = () => false
|
||||||
const parentID = createMemo(() => info()?.parentID)
|
const parentID = createMemo(() => info()?.parentID)
|
||||||
const parent = createMemo(() => {
|
const parent = createMemo(() => {
|
||||||
const id = parentID()
|
const id = parentID()
|
||||||
@@ -314,10 +313,7 @@ export function MessageTimeline(props: {
|
|||||||
if (!id) return emptyMessages
|
if (!id) return emptyMessages
|
||||||
return sync().data.message[id] ?? emptyMessages
|
return sync().data.message[id] ?? emptyMessages
|
||||||
})
|
})
|
||||||
const parentTitle = createMemo(() => {
|
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
|
||||||
const session = parent()
|
|
||||||
return session ? displayLabel(session) : language.t("command.session.new")
|
|
||||||
})
|
|
||||||
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
|
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
|
||||||
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
||||||
const childTaskDescription = createMemo(() => {
|
const childTaskDescription = createMemo(() => {
|
||||||
@@ -335,7 +331,7 @@ export function MessageTimeline(props: {
|
|||||||
if (value) return value
|
if (value) return value
|
||||||
return language.t("command.session.new")
|
return language.t("command.session.new")
|
||||||
})
|
})
|
||||||
const showHeader = createMemo(() => !!(titleLabel() || parentID()))
|
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||||
const projection = createTimelineProjection({
|
const projection = createTimelineProjection({
|
||||||
messages: sessionMessages,
|
messages: sessionMessages,
|
||||||
userMessages: () => props.userMessages,
|
userMessages: () => props.userMessages,
|
||||||
@@ -665,14 +661,16 @@ export function MessageTimeline(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shareMutation = useMutation(() => ({
|
const shareMutation = useMutation(() => ({
|
||||||
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }),
|
// TODO: Restore sharing when the V2 client exposes a session sharing API.
|
||||||
|
mutationFn: async (_id: string) => Promise.reject(new Error("Session sharing is unavailable")),
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error("Failed to share session", err)
|
console.error("Failed to share session", err)
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const unshareMutation = useMutation(() => ({
|
const unshareMutation = useMutation(() => ({
|
||||||
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }),
|
// TODO: Restore unsharing when the V2 client exposes a session sharing API.
|
||||||
|
mutationFn: async (_id: string) => Promise.reject(new Error("Session sharing is unavailable")),
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error("Failed to unshare session", err)
|
console.error("Failed to unshare session", err)
|
||||||
},
|
},
|
||||||
@@ -680,7 +678,7 @@ export function MessageTimeline(props: {
|
|||||||
|
|
||||||
const titleMutation = useMutation(() => ({
|
const titleMutation = useMutation(() => ({
|
||||||
mutationFn: (input: { id: string; title: string }) =>
|
mutationFn: (input: { id: string; title: string }) =>
|
||||||
sdk().currentApi.session.rename({ sessionID: input.id, title: input.title }),
|
sdk().api.session.rename({ sessionID: input.id, title: input.title }),
|
||||||
onSuccess: (_, input) => {
|
onSuccess: (_, input) => {
|
||||||
sync().set(
|
sync().set(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
@@ -818,14 +816,12 @@ export function MessageTimeline(props: {
|
|||||||
const archiveSession = async (sessionID: string) => {
|
const archiveSession = async (sessionID: string) => {
|
||||||
const session = sync().session.get(sessionID)
|
const session = sync().session.get(sessionID)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if ((await sdk().protocol) !== "v1") return
|
|
||||||
|
|
||||||
const sessions = sync().data.session ?? []
|
const sessions = sync().data.session ?? []
|
||||||
const index = sessions.findIndex((s) => s.id === sessionID)
|
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||||
|
|
||||||
await sdk()
|
// TODO: Restore archiving when the V2 client exposes a session archive API.
|
||||||
.client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } })
|
await Promise.reject(new Error("Session archiving is unavailable"))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
sync().set(
|
sync().set(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
@@ -854,7 +850,7 @@ export function MessageTimeline(props: {
|
|||||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||||
|
|
||||||
const result = await sdk()
|
const result = await sdk()
|
||||||
.currentApi.session.remove({ sessionID })
|
.api.session.remove({ sessionID })
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -918,10 +914,9 @@ export function MessageTimeline(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DialogDeleteSession(props: { sessionID: string }) {
|
function DialogDeleteSession(props: { sessionID: string }) {
|
||||||
const name = createMemo(() => {
|
const name = createMemo(
|
||||||
const session = sync().session.get(props.sessionID)
|
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
||||||
return session ? displayLabel(session) : language.t("command.session.new")
|
)
|
||||||
})
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
await deleteSession(props.sessionID)
|
await deleteSession(props.sessionID)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
@@ -1574,9 +1569,7 @@ export function MessageTimeline(props: {
|
|||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
{/* TODO: Restore archive when the V2 client exposes session archive. */}
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||||
@@ -1645,9 +1638,7 @@ export function MessageTimeline(props: {
|
|||||||
{language.t("session.share.action.share")}...
|
{language.t("session.share.action.share")}...
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
{/* TODO: Restore archive when the V2 client exposes session archive. */}
|
||||||
{language.t("common.archive")}
|
|
||||||
</MenuV2.Item>
|
|
||||||
<MenuV2.Separator />
|
<MenuV2.Separator />
|
||||||
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
|
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
|
||||||
{language.t("common.delete")}...
|
{language.t("common.delete")}...
|
||||||
|
|||||||
@@ -38,19 +38,17 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
|
|||||||
instance.scrollOffset = offset
|
instance.scrollOffset = offset
|
||||||
})
|
})
|
||||||
|
|
||||||
|
document.body.append(unrelated)
|
||||||
|
unrelated.remove()
|
||||||
|
await frames(2)
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
|
||||||
route.remove()
|
route.remove()
|
||||||
document.body.append(route)
|
document.body.append(route)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
await frames(3)
|
await frames(3)
|
||||||
expect(calls).toEqual([[0, false]])
|
expect(calls).toEqual([[0, false]])
|
||||||
|
|
||||||
instance.scrollOffset = 79_400
|
|
||||||
document.body.append(unrelated)
|
|
||||||
unrelated.remove()
|
|
||||||
await frames(2)
|
|
||||||
expect(calls).toEqual([[0, false]])
|
|
||||||
|
|
||||||
instance.scrollOffset = 0
|
|
||||||
route.remove()
|
route.remove()
|
||||||
document.body.append(route)
|
document.body.append(route)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|||||||
@@ -193,10 +193,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = await sdk()
|
// TODO: Restore sharing when the V2 client exposes a session sharing API.
|
||||||
.client.session.share({ sessionID })
|
const url = undefined
|
||||||
.then((res) => res.data?.share?.url)
|
|
||||||
.catch(() => undefined)
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("toast.session.share.failed.title"),
|
title: language.t("toast.session.share.failed.title"),
|
||||||
@@ -213,22 +211,12 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
await sdk()
|
// TODO: Restore unsharing when the V2 client exposes a session sharing API.
|
||||||
.client.session.unshare({ sessionID })
|
showToast({
|
||||||
.then(() =>
|
title: language.t("toast.session.unshare.failed.title"),
|
||||||
showToast({
|
description: language.t("toast.session.unshare.failed.description"),
|
||||||
title: language.t("toast.session.unshare.success.title"),
|
variant: "error",
|
||||||
description: language.t("toast.session.unshare.success.description"),
|
})
|
||||||
variant: "success",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.catch(() =>
|
|
||||||
showToast({
|
|
||||||
title: language.t("toast.session.unshare.failed.title"),
|
|
||||||
description: language.t("toast.session.unshare.failed.description"),
|
|
||||||
variant: "error",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openFile = () => {
|
const openFile = () => {
|
||||||
@@ -306,7 +294,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
const owner = sessionOwnership.capture()
|
const owner = sessionOwnership.capture()
|
||||||
const session = sdk().currentApi.session
|
const session = sdk().api.session
|
||||||
const directory = sdk().directory
|
const directory = sdk().directory
|
||||||
const promptSession = prompt.capture()
|
const promptSession = prompt.capture()
|
||||||
const revert = info()?.revert?.messageID
|
const revert = info()?.revert?.messageID
|
||||||
@@ -334,7 +322,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
const owner = sessionOwnership.capture()
|
const owner = sessionOwnership.capture()
|
||||||
const session = sdk().currentApi.session
|
const session = sdk().api.session
|
||||||
const messages = userMessages()
|
const messages = userMessages()
|
||||||
const promptSession = prompt.capture()
|
const promptSession = prompt.capture()
|
||||||
|
|
||||||
@@ -366,7 +354,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
await sdk().currentApi.session.compact({ sessionID })
|
await sdk().api.session.compact({ sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
const fork = () => {
|
const fork = () => {
|
||||||
@@ -377,7 +365,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shareCmds = () => {
|
const shareCmds = () => {
|
||||||
if (sync().data.config.share === "disabled") return []
|
// TODO: Restore these commands when the V2 client exposes session sharing.
|
||||||
|
// if (sync().data.config.share === "disabled") return []
|
||||||
|
return []
|
||||||
|
/*
|
||||||
return [
|
return [
|
||||||
sessionCommand({
|
sessionCommand({
|
||||||
id: "session.share",
|
id: "session.share",
|
||||||
@@ -398,6 +389,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
onSelect: unshare,
|
onSelect: unshare,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionCmds = () => [
|
const sessionCmds = () => [
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
|||||||
|
|
||||||
const readFile = async (path: string) =>
|
const readFile = async (path: string) =>
|
||||||
sdk()
|
sdk()
|
||||||
.currentApi.file.read({ path, location: { directory: sdk().directory } })
|
.api.file.read({ path, location: { directory: sdk().directory } })
|
||||||
.then((content) => ({ type: "text" as const, content: new TextDecoder().decode(content) }))
|
.then((data) => ({ type: "text" as const, content: new TextDecoder().decode(data) }))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.debug("[session-review-v2] failed to read file", { path, error })
|
console.debug("[session-review-v2] failed to read file", { path, error })
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
+131
-39
@@ -1,59 +1,57 @@
|
|||||||
import type {
|
import type {
|
||||||
Agent,
|
EventSubscribeOutput,
|
||||||
Config,
|
FileDiffInfo,
|
||||||
Event,
|
FileDiffLegacyInfo,
|
||||||
FileContent,
|
ProjectListOutput,
|
||||||
FileNode,
|
|
||||||
LspStatus,
|
|
||||||
Path,
|
|
||||||
PermissionRequest,
|
|
||||||
Project,
|
|
||||||
Provider,
|
|
||||||
ProviderAuthResponse,
|
|
||||||
ProviderListResponse,
|
|
||||||
QuestionAnswer,
|
QuestionAnswer,
|
||||||
|
QuestionInfo,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
Session,
|
SessionInfo,
|
||||||
SessionNotFoundError,
|
SessionNotFoundError,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SessionV2Info,
|
SessionV1Info,
|
||||||
SnapshotFileDiff,
|
SessionsResponse,
|
||||||
Todo,
|
} from "@opencode-ai/client/promise"
|
||||||
V2SessionListResponse,
|
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
VcsFileDiff,
|
|
||||||
VcsInfo,
|
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
Agent,
|
|
||||||
Config,
|
|
||||||
Event,
|
|
||||||
FileContent,
|
|
||||||
FileNode,
|
|
||||||
LspStatus,
|
|
||||||
Path,
|
|
||||||
PermissionRequest,
|
|
||||||
Project,
|
|
||||||
Provider,
|
|
||||||
ProviderAuthResponse,
|
|
||||||
ProviderListResponse,
|
|
||||||
QuestionAnswer,
|
QuestionAnswer,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
Session,
|
|
||||||
SessionNotFoundError,
|
SessionNotFoundError,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SessionV2Info,
|
|
||||||
SnapshotFileDiff,
|
|
||||||
Todo,
|
|
||||||
V2SessionListResponse,
|
|
||||||
VcsFileDiff,
|
|
||||||
VcsInfo,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Project = Omit<ProjectListOutput[number], "canonical"> & { worktree: string }
|
||||||
|
export type Session = Omit<SessionV1Info, "title"> & { title: string }
|
||||||
|
export type SessionV2Info = SessionInfo
|
||||||
|
export type V2SessionListResponse = SessionsResponse
|
||||||
|
export type SnapshotFileDiff = FileDiffLegacyInfo
|
||||||
|
export type VcsFileDiff = FileDiffInfo
|
||||||
|
|
||||||
|
type CurrentEvent = EventSubscribeOutput extends infer Item
|
||||||
|
? Item extends { type: infer Type extends string; data: infer Data }
|
||||||
|
? { type: Type; properties: Data }
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type Event =
|
||||||
|
| Exclude<CurrentEvent, { type: "permission.asked" }>
|
||||||
|
| { type: "permission.asked"; properties: PermissionRequest }
|
||||||
|
|
||||||
export type EventSessionError = Extract<Event, { type: "session.error" }>
|
export type EventSessionError = Extract<Event, { type: "session.error" }>
|
||||||
|
|
||||||
|
export type PermissionRequest = {
|
||||||
|
id: string
|
||||||
|
sessionID: string
|
||||||
|
permission: string
|
||||||
|
patterns: string[]
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
always: string[]
|
||||||
|
tool?: { messageID: string; callID: string }
|
||||||
|
}
|
||||||
|
|
||||||
type MessageError =
|
type MessageError =
|
||||||
| { name: "ProviderAuthError"; data: { providerID: string; message: string } }
|
| { name: "ProviderAuthError"; data: { providerID: string; message: string } }
|
||||||
| { name: "UnknownError"; data: { message: string; ref?: string } }
|
| { name: "UnknownError"; data: { message: string; ref?: string } }
|
||||||
@@ -241,6 +239,100 @@ export type Part =
|
|||||||
| RetryPart
|
| RetryPart
|
||||||
| CompactionPart
|
| CompactionPart
|
||||||
|
|
||||||
|
export type Todo = {
|
||||||
|
content: string
|
||||||
|
status: string
|
||||||
|
priority: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileNode = {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
absolute: string
|
||||||
|
type: "file" | "directory"
|
||||||
|
ignored: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileContent = {
|
||||||
|
type: "text" | "binary"
|
||||||
|
content: string
|
||||||
|
diff?: string
|
||||||
|
patch?: {
|
||||||
|
oldFileName: string
|
||||||
|
newFileName: string
|
||||||
|
oldHeader?: string
|
||||||
|
newHeader?: string
|
||||||
|
hunks: Array<{
|
||||||
|
oldStart: number
|
||||||
|
oldLines: number
|
||||||
|
newStart: number
|
||||||
|
newLines: number
|
||||||
|
lines: string[]
|
||||||
|
}>
|
||||||
|
index?: string
|
||||||
|
}
|
||||||
|
encoding?: "base64"
|
||||||
|
mimeType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Path = {
|
||||||
|
home: string
|
||||||
|
state: string
|
||||||
|
config: string
|
||||||
|
worktree: string
|
||||||
|
directory: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VcsInfo = { branch?: string; default_branch?: string }
|
||||||
|
export type LspStatus = { id: string; name: string; root: string; status: "connected" | "error" }
|
||||||
|
|
||||||
|
export type Agent = {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
mode: "subagent" | "primary" | "all"
|
||||||
|
native?: boolean
|
||||||
|
hidden?: boolean
|
||||||
|
topP?: number
|
||||||
|
temperature?: number
|
||||||
|
color?: string
|
||||||
|
permission: Array<{ permission: string; pattern: string; action: "allow" | "deny" | "ask" }>
|
||||||
|
model?: { modelID: string; providerID: string }
|
||||||
|
variant?: string
|
||||||
|
prompt?: string
|
||||||
|
options: Record<string, unknown>
|
||||||
|
steps?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Provider = NormalizedProviderListResponse["all"] extends Map<string, infer Item> ? Item : never
|
||||||
|
export type Model = Provider["models"][string]
|
||||||
|
export type ProviderListResponse = NormalizedProviderListResponse
|
||||||
|
|
||||||
|
export type ProviderAuthResponse = Record<string, unknown>
|
||||||
|
|
||||||
|
export type Config = {
|
||||||
|
model?: string
|
||||||
|
small_model?: string
|
||||||
|
default_agent?: string
|
||||||
|
username?: string
|
||||||
|
share?: "manual" | "auto" | "disabled"
|
||||||
|
autoshare?: boolean
|
||||||
|
shell?: string
|
||||||
|
plugin?: Array<string | [string, Record<string, unknown>]>
|
||||||
|
provider?: Record<string, { npm?: string; models?: Record<string, unknown> }>
|
||||||
|
mcp?: Record<string, unknown>
|
||||||
|
agent?: Record<string, unknown>
|
||||||
|
command?: Record<string, unknown>
|
||||||
|
instructions?: string[]
|
||||||
|
disabled_providers?: string[]
|
||||||
|
enabled_providers?: string[]
|
||||||
|
permission?: string | Record<string, unknown>
|
||||||
|
tools?: Record<string, boolean>
|
||||||
|
experimental?: Record<string, unknown>
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
export type TextPartInput = Omit<TextPart, "id" | "sessionID" | "messageID"> & { id?: string }
|
export type TextPartInput = Omit<TextPart, "id" | "sessionID" | "messageID"> & { id?: string }
|
||||||
export type FilePartInput = Omit<FilePart, "id" | "sessionID" | "messageID"> & { id?: string }
|
export type FilePartInput = Omit<FilePart, "id" | "sessionID" | "messageID"> & { id?: string }
|
||||||
export type AgentPartInput = Omit<AgentPart, "id" | "sessionID" | "messageID"> & { id?: string }
|
export type AgentPartInput = Omit<AgentPart, "id" | "sessionID" | "messageID"> & { id?: string }
|
||||||
|
|
||||||
|
export type Question = QuestionInfo
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ describe("persist localStorage resilience", () => {
|
|||||||
current,
|
current,
|
||||||
legacyStore,
|
legacyStore,
|
||||||
stores: [],
|
stores: [],
|
||||||
keys: target.legacy!,
|
keys: target["legacy"]!,
|
||||||
key: target.key,
|
key: target.key,
|
||||||
defaults: { value: 1 },
|
defaults: { value: 1 },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -558,7 +558,7 @@ export function persisted<T>(
|
|||||||
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
|
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
|
||||||
|
|
||||||
const defaults = snapshot(store[0])
|
const defaults = snapshot(store[0])
|
||||||
const legacy = config.legacy ?? []
|
const legacy = config["legacy"] ?? []
|
||||||
|
|
||||||
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
||||||
|
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { createApiForServer, createSdkForServer } from "./server"
|
|
||||||
import { createCompatibleApi } from "./server-compat"
|
|
||||||
|
|
||||||
function setup(
|
|
||||||
protocol: "v1" | "v2" | Promise<"v1" | "v2">,
|
|
||||||
responses?: { vcs?: { branch: string; default_branch: string } },
|
|
||||||
) {
|
|
||||||
const requests: Request[] = []
|
|
||||||
const fetcher = Object.assign(
|
|
||||||
async (input: string | URL | Request, init?: RequestInit) => {
|
|
||||||
const request = new Request(input, init)
|
|
||||||
requests.push(request)
|
|
||||||
if (request.method === "PATCH") {
|
|
||||||
return Response.json({
|
|
||||||
id: "ses_1",
|
|
||||||
slug: "ses_1",
|
|
||||||
projectID: "project",
|
|
||||||
directory: "/repo",
|
|
||||||
title: "Session",
|
|
||||||
version: "1",
|
|
||||||
time: { created: 1, updated: 1 },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (request.method === "POST" && request.url.endsWith("/prompt_async"))
|
|
||||||
return new Response(undefined, { status: 204 })
|
|
||||||
if (request.method === "POST" && request.url.endsWith("/prompt")) {
|
|
||||||
return Response.json({
|
|
||||||
id: "msg_1",
|
|
||||||
sessionID: "ses_1",
|
|
||||||
timeCreated: 1,
|
|
||||||
type: "user",
|
|
||||||
data: { text: "hello" },
|
|
||||||
delivery: "steer",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (request.method === "GET" && new URL(request.url).pathname === "/vcs")
|
|
||||||
return Response.json(responses?.vcs ?? {})
|
|
||||||
if (request.method === "GET") return Response.json([])
|
|
||||||
return new Response(undefined, { status: 204 })
|
|
||||||
},
|
|
||||||
{ preconnect: globalThis.fetch.preconnect },
|
|
||||||
)
|
|
||||||
const server = { url: "http://localhost:4096" }
|
|
||||||
const api = createCompatibleApi({
|
|
||||||
protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol,
|
|
||||||
current: createApiForServer({ server, fetch: fetcher }),
|
|
||||||
legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }),
|
|
||||||
directory: "/repo",
|
|
||||||
})
|
|
||||||
return { api, requests }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("createCompatibleApi", () => {
|
|
||||||
/*
|
|
||||||
test("routes V1 archive through the legacy session update", async () => {
|
|
||||||
const { api, requests } = setup("v1")
|
|
||||||
await api.session.archive({ sessionID: "ses_1", directory: "/repo" })
|
|
||||||
|
|
||||||
const url = new URL(requests[0]!.url)
|
|
||||||
expect(url.pathname).toBe("/session/ses_1")
|
|
||||||
expect(requests[0]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
|
|
||||||
expect(requests[0]!.method).toBe("PATCH")
|
|
||||||
expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } })
|
|
||||||
})
|
|
||||||
*/
|
|
||||||
|
|
||||||
test("converts current prompts to the V1 prompt contract", async () => {
|
|
||||||
const { api, requests } = setup("v1")
|
|
||||||
await api.session.prompt({
|
|
||||||
sessionID: "ses_1",
|
|
||||||
id: "msg_1",
|
|
||||||
text: "hello @src/index.ts",
|
|
||||||
agent: "build",
|
|
||||||
model: { providerID: "provider", modelID: "model" },
|
|
||||||
files: [
|
|
||||||
{ uri: "file:///repo/src/index.ts", name: "index.ts", mention: { text: "@src/index.ts", start: 6, end: 19 } },
|
|
||||||
{ uri: "data:text/plain;base64,aGVsbG8=", name: "notes.txt" },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async")
|
|
||||||
const body = await requests[0]!.json()
|
|
||||||
expect(body).toMatchObject({
|
|
||||||
messageID: "msg_1",
|
|
||||||
agent: "build",
|
|
||||||
model: { providerID: "provider", modelID: "model" },
|
|
||||||
parts: [
|
|
||||||
{ type: "text", text: "hello @src/index.ts" },
|
|
||||||
{
|
|
||||||
type: "file",
|
|
||||||
mime: "text/plain",
|
|
||||||
url: "file:///repo/src/index.ts",
|
|
||||||
filename: "index.ts",
|
|
||||||
source: {
|
|
||||||
type: "file",
|
|
||||||
text: { value: "@src/index.ts", start: 6, end: 19 },
|
|
||||||
path: "file:///repo/src/index.ts",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "file",
|
|
||||||
mime: "text/plain",
|
|
||||||
url: "data:text/plain;base64,aGVsbG8=",
|
|
||||||
filename: "notes.txt",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(body.parts[2]).not.toHaveProperty("source")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves original parts for V1 optimistic reconciliation", async () => {
|
|
||||||
const { api, requests } = setup("v1")
|
|
||||||
await api.session.prompt({
|
|
||||||
sessionID: "ses_1",
|
|
||||||
id: "msg_1",
|
|
||||||
text: "look",
|
|
||||||
files: [{ uri: "data:image/png;base64,AAAA", name: "image.png" }],
|
|
||||||
legacyParts: [
|
|
||||||
{ id: "prt_text", type: "text", text: "look" },
|
|
||||||
{ id: "prt_image", type: "file", mime: "image/png", url: "data:image/png;base64,AAAA", filename: "image.png" },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect((await requests[0]!.json()).parts).toEqual([
|
|
||||||
{ id: "prt_text", type: "text", text: "look" },
|
|
||||||
{ id: "prt_image", type: "file", mime: "image/png", url: "data:image/png;base64,AAAA", filename: "image.png" },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("resolves protocol detection once across implementation methods", async () => {
|
|
||||||
let detections = 0
|
|
||||||
const resolved = Promise.resolve<"v1" | "v2">("v2")
|
|
||||||
const protocol = new Proxy(resolved, {
|
|
||||||
get(target, property) {
|
|
||||||
if (property !== "then") return Reflect.get(target, property, target)
|
|
||||||
detections++
|
|
||||||
return target.then.bind(target)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const { api } = setup(protocol)
|
|
||||||
|
|
||||||
await api.session.list()
|
|
||||||
await api.session.list()
|
|
||||||
|
|
||||||
expect(detections).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
/*
|
|
||||||
test("keeps V2 session actions on the current API", async () => {
|
|
||||||
const { api, requests } = setup("v2")
|
|
||||||
await api.session.archive({ sessionID: "ses_1" })
|
|
||||||
|
|
||||||
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
|
|
||||||
expect(requests[0]!.method).toBe("POST")
|
|
||||||
})
|
|
||||||
*/
|
|
||||||
|
|
||||||
test("uses the global V1 session search endpoint", async () => {
|
|
||||||
const { api, requests } = setup("v1")
|
|
||||||
await api.session.list({ parentID: null, search: "session", limit: 50 })
|
|
||||||
|
|
||||||
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
|
|
||||||
})
|
|
||||||
|
|
||||||
/*
|
|
||||||
test("projects the V1 default branch", async () => {
|
|
||||||
const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } })
|
|
||||||
|
|
||||||
expect(await api.vcs.get({ location: { directory: "/repo" } })).toMatchObject({
|
|
||||||
data: { branch: "feature", defaultBranch: "dev" },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
*/
|
|
||||||
|
|
||||||
test("translates current file searches to the V1 dirs parameter", async () => {
|
|
||||||
const { api, requests } = setup("v1")
|
|
||||||
await api.file.find({ location: { directory: "/repo" }, query: "src", type: "file", limit: 20 })
|
|
||||||
|
|
||||||
const url = new URL(requests[0]!.url)
|
|
||||||
expect(url.pathname).toBe("/find/file")
|
|
||||||
expect(url.searchParams.get("dirs")).toBe("false")
|
|
||||||
expect(url.searchParams.get("limit")).toBe("20")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("routes V1 permission replies through the requested directory", async () => {
|
|
||||||
const { api, requests } = setup("v1")
|
|
||||||
await api.permission.reply({
|
|
||||||
sessionID: "ses_1",
|
|
||||||
requestID: "permission_1",
|
|
||||||
reply: "once",
|
|
||||||
location: { directory: "/other" },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/permissions/permission_1")
|
|
||||||
expect(new URL(requests[0]!.url).searchParams.get("directory")).toBe("/other")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("disposes the V1 instance after connecting a provider", async () => {
|
|
||||||
const { api, requests } = setup("v1")
|
|
||||||
|
|
||||||
await api.integration.connect.key({
|
|
||||||
integrationID: "openrouter",
|
|
||||||
key: "secret",
|
|
||||||
location: { directory: "/repo" },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(requests.map((request) => new URL(request.url).pathname)).toEqual([
|
|
||||||
"/auth/openrouter",
|
|
||||||
"/instance/dispose",
|
|
||||||
"/instance/dispose",
|
|
||||||
])
|
|
||||||
expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
|
|
||||||
expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("disposes the V1 instance after completing provider OAuth", async () => {
|
|
||||||
const { api, requests } = setup("v1")
|
|
||||||
|
|
||||||
await api.integration.oauth.complete({
|
|
||||||
integrationID: "openrouter",
|
|
||||||
attemptID: "openrouter:0",
|
|
||||||
code: "code",
|
|
||||||
location: { directory: "/repo" },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(requests.map((request) => new URL(request.url).pathname)).toEqual([
|
|
||||||
"/provider/openrouter/oauth/callback",
|
|
||||||
"/instance/dispose",
|
|
||||||
"/instance/dispose",
|
|
||||||
])
|
|
||||||
expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
|
|
||||||
expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,523 +0,0 @@
|
|||||||
import type { ServerApi } from "./server"
|
|
||||||
import type { ServerProtocol } from "./server-protocol"
|
|
||||||
import type { AgentPartInput, FilePartInput, Session, TextPartInput } from "@/types"
|
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import type {
|
|
||||||
Project,
|
|
||||||
ProjectCurrent,
|
|
||||||
SessionApi,
|
|
||||||
SessionCommandInput,
|
|
||||||
SessionCommandOutput,
|
|
||||||
SessionCompactInput,
|
|
||||||
SessionCompactOutput,
|
|
||||||
SessionInfo,
|
|
||||||
SessionPromptInput,
|
|
||||||
SessionPromptOutput,
|
|
||||||
SessionShellInput,
|
|
||||||
SessionShellOutput,
|
|
||||||
} from "@opencode-ai/client/promise"
|
|
||||||
|
|
||||||
type LegacyClient = OpencodeClient
|
|
||||||
type LegacyFor = (directory?: string) => LegacyClient
|
|
||||||
type CompatibleSessionApi = Omit<
|
|
||||||
SessionApi,
|
|
||||||
"prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove"
|
|
||||||
> & {
|
|
||||||
prompt: (input: SessionPromptInput & LegacyPrompt) => Promise<SessionPromptOutput>
|
|
||||||
command: (input: SessionCommandInput) => Promise<SessionCommandOutput>
|
|
||||||
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
|
|
||||||
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
|
|
||||||
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
|
|
||||||
// archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
|
|
||||||
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
|
|
||||||
}
|
|
||||||
type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
|
|
||||||
reply: (
|
|
||||||
input: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } },
|
|
||||||
) => ReturnType<ServerApi["permission"]["reply"]>
|
|
||||||
}
|
|
||||||
export type CompatibleApi = Omit<ServerApi, "session" | "permission"> & {
|
|
||||||
readonly session: CompatibleSessionApi
|
|
||||||
readonly permission: CompatiblePermissionApi
|
|
||||||
}
|
|
||||||
type LegacyPrompt = {
|
|
||||||
agent?: string
|
|
||||||
model?: { providerID: string; modelID: string }
|
|
||||||
variant?: string
|
|
||||||
legacyParts?: (TextPartInput | FilePartInput | AgentPartInput)[]
|
|
||||||
}
|
|
||||||
type LegacyLocation = { directory?: string }
|
|
||||||
type CompatibleInput = {
|
|
||||||
protocol: Promise<ServerProtocol>
|
|
||||||
current: ServerApi
|
|
||||||
legacy: LegacyFor
|
|
||||||
directory?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function mime(uri: string) {
|
|
||||||
const match = /^data:([^;,]+)/.exec(uri)
|
|
||||||
return match?.[1] ?? "application/octet-stream"
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionInfo(session: Session): SessionInfo {
|
|
||||||
return {
|
|
||||||
id: session.id,
|
|
||||||
parentID: session.parentID,
|
|
||||||
projectID: session.projectID,
|
|
||||||
agent: session.agent,
|
|
||||||
model: session.model && {
|
|
||||||
id: session.model.id,
|
|
||||||
providerID: session.model.providerID,
|
|
||||||
variant: session.model.variant,
|
|
||||||
},
|
|
||||||
cost: session.cost ?? 0,
|
|
||||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
time: session.time,
|
|
||||||
title: session.title,
|
|
||||||
location: { directory: session.directory, workspaceID: session.workspaceID },
|
|
||||||
subpath: session.path,
|
|
||||||
revert: session.revert && {
|
|
||||||
messageID: session.revert.messageID,
|
|
||||||
partID: session.revert.partID,
|
|
||||||
snapshot: session.revert.snapshot,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCompatibleApi(input: CompatibleInput): CompatibleApi {
|
|
||||||
const v1 = createV1Api(input)
|
|
||||||
return lazyApi(
|
|
||||||
input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)),
|
|
||||||
input.current,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function lazyApi<T extends object>(implementation: Promise<T>, shape: T): T {
|
|
||||||
const cache = new Map<PropertyKey, unknown>()
|
|
||||||
return new Proxy(shape, {
|
|
||||||
get(target, property, receiver) {
|
|
||||||
const sample = Reflect.get(target, property, receiver)
|
|
||||||
if (typeof sample === "function") {
|
|
||||||
return (...args: unknown[]) =>
|
|
||||||
implementation.then((value) => {
|
|
||||||
const method = Reflect.get(value, property)
|
|
||||||
if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`)
|
|
||||||
return Reflect.apply(method, value, args)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (sample === null || typeof sample !== "object") return sample
|
|
||||||
if (cache.has(property)) return cache.get(property)
|
|
||||||
const nested = lazyApi(
|
|
||||||
implementation.then((value) => {
|
|
||||||
const result = Reflect.get(value, property)
|
|
||||||
if (result === null || typeof result !== "object") {
|
|
||||||
throw new Error(`API namespace unavailable: ${String(property)}`)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}),
|
|
||||||
sample,
|
|
||||||
)
|
|
||||||
cache.set(property, nested)
|
|
||||||
return nested
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function createV1Api(input: CompatibleInput): CompatibleApi {
|
|
||||||
const directory = (location?: { directory?: string }) => location?.directory ?? input.directory
|
|
||||||
const legacy = (location?: { directory?: string }) => input.legacy(directory(location))
|
|
||||||
const located = <T>(data: T, value?: { directory?: string }) => ({
|
|
||||||
location: {
|
|
||||||
directory: directory(value) ?? "",
|
|
||||||
project: { id: "", directory: directory(value) ?? "", canonical: directory(value) ?? "" },
|
|
||||||
},
|
|
||||||
data,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
...input.current,
|
|
||||||
session: {
|
|
||||||
...input.current.session,
|
|
||||||
async list(
|
|
||||||
value?: Parameters<ServerApi["session"]["list"]>[0],
|
|
||||||
options?: Parameters<ServerApi["session"]["list"]>[1],
|
|
||||||
) {
|
|
||||||
if (!value?.directory && value?.search !== undefined) {
|
|
||||||
const result = await legacy().experimental.session.list(
|
|
||||||
{
|
|
||||||
roots: value.parentID === null ? true : undefined,
|
|
||||||
search: value.search,
|
|
||||||
limit: value.limit,
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
)
|
|
||||||
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
|
|
||||||
}
|
|
||||||
const result = await legacy({ directory: value?.directory }).session.list({
|
|
||||||
directory: value?.directory,
|
|
||||||
roots: value?.parentID === null ? true : undefined,
|
|
||||||
search: value?.search,
|
|
||||||
limit: value?.limit,
|
|
||||||
})
|
|
||||||
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
|
|
||||||
},
|
|
||||||
async create(value?: Parameters<ServerApi["session"]["create"]>[0]) {
|
|
||||||
const result = await legacy(value?.location ?? undefined).session.create({
|
|
||||||
directory: directory(value?.location ?? undefined),
|
|
||||||
})
|
|
||||||
if (!result.data) throw new Error("Failed to create session")
|
|
||||||
return sessionInfo(result.data)
|
|
||||||
},
|
|
||||||
async get(value: Parameters<ServerApi["session"]["get"]>[0]) {
|
|
||||||
const result = await legacy().session.get(value)
|
|
||||||
if (!result.data) throw new Error(`Session not found: ${value.sessionID}`)
|
|
||||||
return sessionInfo(result.data)
|
|
||||||
},
|
|
||||||
async active() {
|
|
||||||
const result = await legacy().session.status()
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(result.data ?? {}).flatMap(([sessionID, status]) =>
|
|
||||||
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
|
|
||||||
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
|
|
||||||
},
|
|
||||||
// async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
|
|
||||||
// await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
|
|
||||||
// },
|
|
||||||
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
|
|
||||||
await legacy(value).session.delete(value)
|
|
||||||
},
|
|
||||||
async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) {
|
|
||||||
const result = await legacy().session.fork(value)
|
|
||||||
if (!result.data) throw new Error("Failed to fork session")
|
|
||||||
return sessionInfo(result.data)
|
|
||||||
},
|
|
||||||
async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
|
|
||||||
await legacy().session.abort(value)
|
|
||||||
},
|
|
||||||
async prompt(value: SessionPromptInput & LegacyPrompt) {
|
|
||||||
await legacy().session.promptAsync({
|
|
||||||
sessionID: value.sessionID,
|
|
||||||
messageID: value.id ?? undefined,
|
|
||||||
agent: value.agent,
|
|
||||||
model: value.model,
|
|
||||||
variant: value.variant,
|
|
||||||
parts: value.legacyParts ?? [
|
|
||||||
{ type: "text", text: value.text },
|
|
||||||
...(value.files ?? []).map((file) => ({
|
|
||||||
type: "file" as const,
|
|
||||||
mime: file.mention ? "text/plain" : mime(file.uri),
|
|
||||||
url: file.uri,
|
|
||||||
filename: file.name,
|
|
||||||
source: file.mention
|
|
||||||
? {
|
|
||||||
type: "file" as const,
|
|
||||||
text: { value: file.mention.text, start: file.mention.start, end: file.mention.end },
|
|
||||||
path: file.uri,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
...(value.agents ?? []).map((agent) => ({
|
|
||||||
type: "agent" as const,
|
|
||||||
name: agent.name,
|
|
||||||
source: agent.mention
|
|
||||||
? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end }
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
id: value.id ?? "",
|
|
||||||
sessionID: value.sessionID,
|
|
||||||
timeCreated: Date.now(),
|
|
||||||
type: "user",
|
|
||||||
data: { text: value.text },
|
|
||||||
delivery: value.delivery ?? "steer",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async command(value: SessionCommandInput) {
|
|
||||||
await legacy().session.command({
|
|
||||||
sessionID: value.sessionID,
|
|
||||||
messageID: value.id ?? undefined,
|
|
||||||
command: value.command,
|
|
||||||
arguments: value.arguments ?? "",
|
|
||||||
agent: value.agent ?? undefined,
|
|
||||||
model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined,
|
|
||||||
variant: value.model?.variant,
|
|
||||||
parts: value.files?.map((file) => ({
|
|
||||||
type: "file" as const,
|
|
||||||
mime: mime(file.uri),
|
|
||||||
url: file.uri,
|
|
||||||
filename: file.name,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
id: value.id ?? "",
|
|
||||||
sessionID: value.sessionID,
|
|
||||||
timeCreated: Date.now(),
|
|
||||||
type: "user",
|
|
||||||
data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() },
|
|
||||||
delivery: value.delivery ?? "steer",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async shell(value: SessionShellInput & LegacyPrompt) {
|
|
||||||
await legacy().session.shell({
|
|
||||||
sessionID: value.sessionID,
|
|
||||||
command: value.command,
|
|
||||||
agent: value.agent,
|
|
||||||
model: value.model,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => {
|
|
||||||
if (!value.model) throw new Error("A model is required to compact a V1 session")
|
|
||||||
await legacy().session.summarize({
|
|
||||||
sessionID: value.sessionID,
|
|
||||||
providerID: value.model.providerID,
|
|
||||||
modelID: value.model.modelID,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
id: value.id ?? "",
|
|
||||||
sessionID: value.sessionID,
|
|
||||||
timeCreated: Date.now(),
|
|
||||||
type: "compaction",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
revert: {
|
|
||||||
stage: async (value: Parameters<ServerApi["session"]["revert"]["stage"]>[0]) => {
|
|
||||||
await legacy().session.revert(value)
|
|
||||||
return { messageID: value.messageID }
|
|
||||||
},
|
|
||||||
clear: async (value: Parameters<ServerApi["session"]["revert"]["clear"]>[0]) => {
|
|
||||||
await legacy().session.unrevert(value)
|
|
||||||
},
|
|
||||||
commit: input.current.session.revert.commit,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
project: {
|
|
||||||
...input.current.project,
|
|
||||||
async list() {
|
|
||||||
return ((await legacy().project.list()).data ?? []).map((project) => ({
|
|
||||||
...project,
|
|
||||||
canonical: project.worktree,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
|
|
||||||
const result = await legacy(value?.location).project.current()
|
|
||||||
if (!result.data) throw new Error("Project not found")
|
|
||||||
return {
|
|
||||||
id: result.data.id,
|
|
||||||
directory: result.data.worktree,
|
|
||||||
canonical: result.data.worktree,
|
|
||||||
} satisfies ProjectCurrent
|
|
||||||
},
|
|
||||||
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
|
|
||||||
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
|
|
||||||
// const result = await legacy({ directory: project?.worktree }).project.update({
|
|
||||||
// ...value,
|
|
||||||
// directory: project?.worktree,
|
|
||||||
// })
|
|
||||||
// if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
|
|
||||||
// return result.data as Project
|
|
||||||
// },
|
|
||||||
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
|
|
||||||
const result = await legacy(value.location).worktree.list()
|
|
||||||
return (result.data ?? []).map((item) => ({ directory: item }))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// path: {
|
|
||||||
// ...input.current.path,
|
|
||||||
// async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
|
|
||||||
// const result = await legacy(value?.location).path.get()
|
|
||||||
// if (!result.data) throw new Error("Path unavailable")
|
|
||||||
// return result.data
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
vcs: {
|
|
||||||
...input.current.vcs,
|
|
||||||
// async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
|
|
||||||
// const result = await legacy(value?.location).vcs.get()
|
|
||||||
// return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
|
|
||||||
// },
|
|
||||||
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
|
|
||||||
const result = await legacy(value?.location).vcs.status()
|
|
||||||
return located(result.data ?? [], value?.location)
|
|
||||||
},
|
|
||||||
async diff(value: Parameters<ServerApi["vcs"]["diff"]>[0]) {
|
|
||||||
const result = await legacy(value.location).vcs.diff({
|
|
||||||
mode: value.mode === "working" ? "git" : value.mode,
|
|
||||||
context: value.context,
|
|
||||||
})
|
|
||||||
return located(
|
|
||||||
(result.data ?? []).map((file) => ({
|
|
||||||
file: file.file,
|
|
||||||
patch: file.patch ?? "",
|
|
||||||
additions: file.additions,
|
|
||||||
deletions: file.deletions,
|
|
||||||
status: file.status ?? "modified",
|
|
||||||
})),
|
|
||||||
value.location,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
file: {
|
|
||||||
...input.current.file,
|
|
||||||
async list(value?: Parameters<ServerApi["file"]["list"]>[0]) {
|
|
||||||
const result = await legacy(value?.location).file.list({ path: value?.path ?? "" })
|
|
||||||
return located(result.data ?? [], value?.location)
|
|
||||||
},
|
|
||||||
async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
|
|
||||||
const result = await legacy(value.location).find.files({
|
|
||||||
query: value.query,
|
|
||||||
dirs: value.type === undefined ? undefined : value.type === "directory" ? "true" : "false",
|
|
||||||
limit: value.limit,
|
|
||||||
})
|
|
||||||
return located(
|
|
||||||
(result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })),
|
|
||||||
value.location,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
integration: {
|
|
||||||
...input.current.integration,
|
|
||||||
async get(value: Parameters<ServerApi["integration"]["get"]>[0]) {
|
|
||||||
const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map(
|
|
||||||
(method, index) =>
|
|
||||||
method.type === "api"
|
|
||||||
? { type: "key" as const, label: method.label }
|
|
||||||
: { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts },
|
|
||||||
)
|
|
||||||
return located(
|
|
||||||
{
|
|
||||||
id: value.integrationID,
|
|
||||||
name: value.integrationID,
|
|
||||||
methods,
|
|
||||||
connections: [],
|
|
||||||
},
|
|
||||||
value.location,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
connect: {
|
|
||||||
...input.current.integration.connect,
|
|
||||||
key: async (value: Parameters<ServerApi["integration"]["connect"]["key"]>[0]) => {
|
|
||||||
await legacy(value.location).auth.set({
|
|
||||||
providerID: value.integrationID,
|
|
||||||
auth: { type: "api", key: value.key },
|
|
||||||
})
|
|
||||||
await legacy(value.location).instance.dispose()
|
|
||||||
await input.legacy().instance.dispose()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
oauth: {
|
|
||||||
...input.current.integration.oauth,
|
|
||||||
connect: async (value: Parameters<ServerApi["integration"]["oauth"]["connect"]>[0]) => {
|
|
||||||
const method = Number(value.methodID)
|
|
||||||
const result = await legacy(value.location).provider.oauth.authorize(
|
|
||||||
{ providerID: value.integrationID, method, inputs: value.inputs },
|
|
||||||
{ throwOnError: true },
|
|
||||||
)
|
|
||||||
if (!result.data) throw new Error("Failed to start OAuth authorization")
|
|
||||||
return located(
|
|
||||||
{
|
|
||||||
attemptID: `${value.integrationID}:${method}`,
|
|
||||||
url: result.data.url,
|
|
||||||
instructions: result.data.instructions,
|
|
||||||
mode: result.data.method,
|
|
||||||
time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 },
|
|
||||||
},
|
|
||||||
value.location,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
complete: async (value: Parameters<ServerApi["integration"]["oauth"]["complete"]>[0]) => {
|
|
||||||
const method = Number(value.attemptID.split(":").at(-1))
|
|
||||||
await legacy(value.location).provider.oauth.callback(
|
|
||||||
{ providerID: value.integrationID, method, code: value.code },
|
|
||||||
{ throwOnError: true },
|
|
||||||
)
|
|
||||||
await legacy(value.location).instance.dispose()
|
|
||||||
await input.legacy().instance.dispose()
|
|
||||||
},
|
|
||||||
status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
|
|
||||||
const method = Number(value.attemptID.split(":").at(-1))
|
|
||||||
await legacy(value.location).provider.oauth.callback(
|
|
||||||
{ providerID: value.integrationID, method },
|
|
||||||
{ throwOnError: true },
|
|
||||||
)
|
|
||||||
await legacy(value.location).instance.dispose()
|
|
||||||
await input.legacy().instance.dispose()
|
|
||||||
return located(
|
|
||||||
{ status: "complete" as const, time: { created: Date.now(), expires: Date.now() } },
|
|
||||||
value.location,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pty: {
|
|
||||||
...input.current.pty,
|
|
||||||
// async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
|
|
||||||
// return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
|
|
||||||
// },
|
|
||||||
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
|
|
||||||
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
|
|
||||||
},
|
|
||||||
async create(value?: Parameters<ServerApi["pty"]["create"]>[0]) {
|
|
||||||
const result = await legacy(value?.location).pty.create({
|
|
||||||
command: value?.command,
|
|
||||||
args: value?.args ? [...value.args] : undefined,
|
|
||||||
cwd: value?.cwd,
|
|
||||||
title: value?.title,
|
|
||||||
env: value?.env,
|
|
||||||
})
|
|
||||||
if (!result.data) throw new Error("Failed to create terminal")
|
|
||||||
return located(result.data, value?.location)
|
|
||||||
},
|
|
||||||
async get(value: Parameters<ServerApi["pty"]["get"]>[0]) {
|
|
||||||
const result = await legacy(value.location).pty.get({ ptyID: value.ptyID })
|
|
||||||
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
|
|
||||||
return located(result.data, value.location)
|
|
||||||
},
|
|
||||||
async update(value: Parameters<ServerApi["pty"]["update"]>[0]) {
|
|
||||||
const result = await legacy(value.location).pty.update({
|
|
||||||
ptyID: value.ptyID,
|
|
||||||
title: value.title,
|
|
||||||
size: value.size,
|
|
||||||
})
|
|
||||||
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
|
|
||||||
return located(result.data, value.location)
|
|
||||||
},
|
|
||||||
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
|
|
||||||
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
|
|
||||||
},
|
|
||||||
// async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
|
|
||||||
// const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
|
|
||||||
// if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
|
|
||||||
// return located(result.data, value.location)
|
|
||||||
// },
|
|
||||||
},
|
|
||||||
permission: {
|
|
||||||
...input.current.permission,
|
|
||||||
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } }) {
|
|
||||||
await legacy(value.location).permission.respond({
|
|
||||||
sessionID: value.sessionID,
|
|
||||||
permissionID: value.requestID,
|
|
||||||
response: value.reply,
|
|
||||||
directory: directory(value.location),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
question: {
|
|
||||||
...input.current.question,
|
|
||||||
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
|
|
||||||
await legacy().question.reply({
|
|
||||||
requestID: value.requestID,
|
|
||||||
answers: value.answers.map((answer) => [...answer]),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async reject(value: Parameters<ServerApi["question"]["reject"]>[0]) {
|
|
||||||
await legacy().question.reject({ requestID: value.requestID })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -29,32 +29,6 @@ describe("checkServerHealth", () => {
|
|||||||
expect(request?.pathname).toBe("/api/health")
|
expect(request?.pathname).toBe("/api/health")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("falls back to the V1 health endpoint", async () => {
|
|
||||||
const paths: string[] = []
|
|
||||||
const fetch = (async (input: RequestInfo | URL) => {
|
|
||||||
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
|
||||||
paths.push(url.pathname)
|
|
||||||
if (url.pathname === "/api/health") return new Response(undefined, { status: 404 })
|
|
||||||
return Response.json({ healthy: true, version: "1.18.4" })
|
|
||||||
}) as unknown as typeof globalThis.fetch
|
|
||||||
|
|
||||||
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
|
|
||||||
expect(paths).toEqual(["/api/health", "/global/health"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("falls back when the current health response is malformed", async () => {
|
|
||||||
const paths: string[] = []
|
|
||||||
const fetch = (async (input: RequestInfo | URL) => {
|
|
||||||
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
|
||||||
paths.push(url.pathname)
|
|
||||||
if (url.pathname === "/api/health") return Response.json({})
|
|
||||||
return Response.json({ healthy: true, version: "1.18.4" })
|
|
||||||
}) as unknown as typeof globalThis.fetch
|
|
||||||
|
|
||||||
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
|
|
||||||
expect(paths).toEqual(["/api/health", "/global/health"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("allows slow servers thirty seconds by default", async () => {
|
test("allows slow servers thirty seconds by default", async () => {
|
||||||
const timeout = Object.getOwnPropertyDescriptor(AbortSignal, "timeout")
|
const timeout = Object.getOwnPropertyDescriptor(AbortSignal, "timeout")
|
||||||
let timeoutMs = 0
|
let timeoutMs = 0
|
||||||
@@ -172,7 +146,7 @@ describe("checkServerHealth", () => {
|
|||||||
retryDelayMs: 1,
|
retryDelayMs: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(count).toBe(6)
|
expect(count).toBe(3)
|
||||||
expect(result).toEqual({ healthy: false })
|
expect(result).toEqual({ healthy: false })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { authTokenFromCredentials, createSdkForServer } from "./server"
|
import { authTokenFromCredentials } from "./server"
|
||||||
import { ClientError, OpenCode } from "@opencode-ai/client"
|
import { ClientError, OpenCode } from "@opencode-ai/client"
|
||||||
import { Accessor, createEffect, onCleanup } from "solid-js"
|
import { Accessor, createEffect, onCleanup } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
@@ -104,10 +104,7 @@ export async function checkServerHealth(
|
|||||||
if ("data" in current && current.data) return current.data
|
if ("data" in current && current.data) return current.data
|
||||||
if (signal?.aborted) return { healthy: false }
|
if (signal?.aborted) return { healthy: false }
|
||||||
|
|
||||||
return createSdkForServer({ server, fetch, signal })
|
return next(count, current.error)
|
||||||
.global.health()
|
|
||||||
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
|
|
||||||
.catch((error) => next(count, error))
|
|
||||||
}
|
}
|
||||||
return attempt(0).finally(() => timeout?.clear?.())
|
return attempt(0).finally(() => timeout?.clear?.())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { detectServerProtocol } from "./server-protocol"
|
|
||||||
|
|
||||||
const server = { url: "http://localhost:4096" }
|
|
||||||
const json = (value: unknown, status = 200) =>
|
|
||||||
new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } })
|
|
||||||
const mockFetch = (run: (input: string | URL | Request) => Promise<Response>) =>
|
|
||||||
Object.assign(run, { preconnect: globalThis.fetch.preconnect })
|
|
||||||
|
|
||||||
describe("detectServerProtocol", () => {
|
|
||||||
test("prefers the legacy health endpoint when both API generations exist", async () => {
|
|
||||||
const fetcher = mockFetch((input) => {
|
|
||||||
const path = new URL(input instanceof Request ? input.url : input).pathname
|
|
||||||
if (path === "/global/health") return Promise.resolve(json({ healthy: true, version: "1.18.4" }))
|
|
||||||
return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(await detectServerProtocol(server, fetcher)).toBe("v1")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("recognizes V2 health by its process identifier", async () => {
|
|
||||||
const fetcher = mockFetch((input) => {
|
|
||||||
const path = new URL(input instanceof Request ? input.url : input).pathname
|
|
||||||
if (path === "/global/health") return Promise.resolve(json({}, 404))
|
|
||||||
return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(await detectServerProtocol(server, fetcher)).toBe("v2")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("recognizes the transitional V1 API health response", async () => {
|
|
||||||
const fetcher = mockFetch((input) => {
|
|
||||||
const path = new URL(input instanceof Request ? input.url : input).pathname
|
|
||||||
if (path === "/global/health") return Promise.resolve(json({}, 404))
|
|
||||||
return Promise.resolve(json({ healthy: true }))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(await detectServerProtocol(server, fetcher)).toBe("v1")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import type { ServerConnection } from "@/context/server"
|
|
||||||
import { authTokenFromCredentials } from "./server"
|
|
||||||
|
|
||||||
export type ServerProtocol = "v1" | "v2"
|
|
||||||
|
|
||||||
function headers(server: ServerConnection.HttpBase) {
|
|
||||||
if (!server.password) return
|
|
||||||
return {
|
|
||||||
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) {
|
|
||||||
const response = await fetch(new URL(path, server.url), {
|
|
||||||
headers: headers(server),
|
|
||||||
signal: AbortSignal.timeout(5_000),
|
|
||||||
})
|
|
||||||
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return
|
|
||||||
const value: unknown = await response.json()
|
|
||||||
if (!value || typeof value !== "object") return
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function detectServerProtocol(
|
|
||||||
server: ServerConnection.HttpBase,
|
|
||||||
fetch: typeof globalThis.fetch,
|
|
||||||
): Promise<ServerProtocol> {
|
|
||||||
const legacy = await probe(server, fetch, "/global/health").catch(() => undefined)
|
|
||||||
if (legacy && "healthy" in legacy && legacy.healthy === true) return "v1"
|
|
||||||
|
|
||||||
const current = await probe(server, fetch, "/api/health").catch(() => undefined)
|
|
||||||
if (current && "pid" in current && typeof current.pid === "number") return "v2"
|
|
||||||
if (current && "healthy" in current && current.healthy === true) return "v1"
|
|
||||||
return "v2"
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
import type { ServerConnection } from "@/context/server"
|
import type { ServerConnection } from "@/context/server"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
@@ -18,29 +17,6 @@ export function authFromToken(token: string | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSdkForServer({
|
|
||||||
server,
|
|
||||||
...config
|
|
||||||
}: Omit<NonNullable<Parameters<typeof createOpencodeClient>[0]>, "baseUrl"> & {
|
|
||||||
server: ServerConnection.HttpBase
|
|
||||||
}) {
|
|
||||||
const auth = (() => {
|
|
||||||
if (!server.password) return
|
|
||||||
return {
|
|
||||||
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
return createOpencodeClient({
|
|
||||||
...config,
|
|
||||||
headers: {
|
|
||||||
...(config.headers instanceof Headers ? Object.fromEntries(config.headers.entries()) : config.headers),
|
|
||||||
...auth,
|
|
||||||
},
|
|
||||||
baseUrl: server.url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createApiForServer(input: {
|
export function createApiForServer(input: {
|
||||||
server: ServerConnection.HttpBase
|
server: ServerConnection.HttpBase
|
||||||
fetch?: typeof globalThis.fetch
|
fetch?: typeof globalThis.fetch
|
||||||
|
|||||||
@@ -3,31 +3,6 @@ import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
|||||||
import { normalizeSessionMessages } from "./session-message"
|
import { normalizeSessionMessages } from "./session-message"
|
||||||
|
|
||||||
describe("normalizeSessionMessages", () => {
|
describe("normalizeSessionMessages", () => {
|
||||||
test("keeps attachments without inventing an empty text part", () => {
|
|
||||||
const source = [
|
|
||||||
{
|
|
||||||
id: "msg_1",
|
|
||||||
type: "user",
|
|
||||||
text: "",
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
data: "aGVsbG8=",
|
|
||||||
mime: "text/plain",
|
|
||||||
name: "note.txt",
|
|
||||||
source: { type: "inline" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
agents: [{ name: "review" }],
|
|
||||||
time: { created: 1 },
|
|
||||||
},
|
|
||||||
] satisfies SessionMessageInfo[]
|
|
||||||
|
|
||||||
const result = normalizeSessionMessages("ses_1", source)
|
|
||||||
|
|
||||||
expect(result.messages).toHaveLength(1)
|
|
||||||
expect(result.parts.get("msg_1")?.map((part) => part.type)).toEqual(["file", "agent"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("projects current turns into stable legacy rendering records", () => {
|
test("projects current turns into stable legacy rendering records", () => {
|
||||||
const source = [
|
const source = [
|
||||||
{ id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } },
|
{ id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } },
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||||
|
|
||||||
|
export function sessionTitle(title?: string) {
|
||||||
|
if (!title) return title
|
||||||
|
const match = title.match(pattern)
|
||||||
|
return match?.[1] ?? title
|
||||||
|
}
|
||||||
@@ -20,61 +20,4 @@ describe("terminalWebSocketURL", () => {
|
|||||||
expect(url.searchParams.get("ticket")).toBe("connect-ticket")
|
expect(url.searchParams.get("ticket")).toBe("connect-ticket")
|
||||||
expect(url.searchParams.has("auth_token")).toBe(false)
|
expect(url.searchParams.has("auth_token")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses query auth without embedding credentials in websocket URL for v1", () => {
|
|
||||||
const url = terminalWebSocketURL({
|
|
||||||
protocol: "v1",
|
|
||||||
url: "http://127.0.0.1:49365",
|
|
||||||
id: "pty_test",
|
|
||||||
directory: "/tmp/project",
|
|
||||||
cursor: 0,
|
|
||||||
sameOrigin: false,
|
|
||||||
username: "opencode",
|
|
||||||
password: "secret",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(url.protocol).toBe("ws:")
|
|
||||||
expect(url.username).toBe("")
|
|
||||||
expect(url.password).toBe("")
|
|
||||||
expect(url.pathname).toBe("/pty/pty_test/connect")
|
|
||||||
expect(url.searchParams.get("directory")).toBe("/tmp/project")
|
|
||||||
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
|
|
||||||
})
|
|
||||||
|
|
||||||
test("omits query auth for same-origin saved credentials for v1", () => {
|
|
||||||
const url = terminalWebSocketURL({
|
|
||||||
protocol: "v1",
|
|
||||||
url: "https://app.example.test",
|
|
||||||
id: "pty_test",
|
|
||||||
directory: "/tmp/project",
|
|
||||||
cursor: 10,
|
|
||||||
sameOrigin: true,
|
|
||||||
username: "opencode",
|
|
||||||
password: "secret",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(url.protocol).toBe("wss:")
|
|
||||||
expect(url.pathname).toBe("/pty/pty_test/connect")
|
|
||||||
expect(url.searchParams.get("directory")).toBe("/tmp/project")
|
|
||||||
expect(url.searchParams.has("auth_token")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses query auth for same-origin credentials from auth_token for v1", () => {
|
|
||||||
const url = terminalWebSocketURL({
|
|
||||||
protocol: "v1",
|
|
||||||
url: "https://app.example.test",
|
|
||||||
id: "pty_test",
|
|
||||||
directory: "/tmp/project",
|
|
||||||
cursor: 10,
|
|
||||||
sameOrigin: true,
|
|
||||||
username: "opencode",
|
|
||||||
password: "secret",
|
|
||||||
authToken: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(url.protocol).toBe("wss:")
|
|
||||||
expect(url.pathname).toBe("/pty/pty_test/connect")
|
|
||||||
expect(url.searchParams.get("directory")).toBe("/tmp/project")
|
|
||||||
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,35 +1,17 @@
|
|||||||
import { authTokenFromCredentials } from "@/utils/server"
|
|
||||||
|
|
||||||
export function terminalWebSocketURL(input: {
|
export function terminalWebSocketURL(input: {
|
||||||
protocol?: "v1" | "v2"
|
|
||||||
url: string
|
url: string
|
||||||
id: string
|
id: string
|
||||||
directory: string
|
directory: string
|
||||||
cursor: number
|
cursor: number
|
||||||
ticket?: string
|
ticket?: string
|
||||||
sameOrigin?: boolean
|
|
||||||
username?: string
|
|
||||||
password?: string
|
|
||||||
authToken?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const isV1 = input.protocol === "v1"
|
const next = new URL(`${input.url}/api/pty/${input.id}/connect`)
|
||||||
const next = new URL(`${input.url}${isV1 ? `/pty/${input.id}/connect` : `/api/pty/${input.id}/connect`}`)
|
next.searchParams.set("location[directory]", input.directory)
|
||||||
if (isV1) {
|
|
||||||
next.searchParams.set("directory", input.directory)
|
|
||||||
} else {
|
|
||||||
next.searchParams.set("location[directory]", input.directory)
|
|
||||||
}
|
|
||||||
next.searchParams.set("cursor", String(input.cursor))
|
next.searchParams.set("cursor", String(input.cursor))
|
||||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||||
if (input.ticket) {
|
if (input.ticket) {
|
||||||
next.searchParams.set("ticket", input.ticket)
|
next.searchParams.set("ticket", input.ticket)
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
if (isV1 && input.password && (!input.sameOrigin || input.authToken)) {
|
|
||||||
next.searchParams.set(
|
|
||||||
"auth_token",
|
|
||||||
authTokenFromCredentials({ username: input.username, password: input.password }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Project } from "@opencode-ai/sdk/v2/client"
|
import type { Project } from "@/types"
|
||||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import { createRoot } from "solid-js"
|
import { createRoot } from "solid-js"
|
||||||
import { createServerSessionEntries } from "@/components/command-palette"
|
import { createServerSessionEntries } from "@/components/command-palette"
|
||||||
|
|||||||
Reference in New Issue
Block a user