mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f840b3e3a2 | |||
| 05c3866788 | |||
| 3eca11a7da | |||
| 794283057a | |||
| 56c33e84a3 | |||
| 73581b3c3b | |||
| 6e4d01f846 | |||
| 8646587c95 | |||
| fb47f06228 | |||
| 3acaa5a359 | |||
| 0726a25142 | |||
| 0df96ddeb0 | |||
| 887673310b | |||
| 693a1bff81 | |||
| b40ed3aa85 | |||
| 732bb9c3cb | |||
| 2fd1660b4d | |||
| 681526d348 | |||
| e78869c849 | |||
| 934935963d | |||
| f3912a2a8a | |||
| 45d58717a4 | |||
| 74e3155ef0 | |||
| 0af6c82563 | |||
| 686127f809 | |||
| 5256655c4d | |||
| d10b652637 | |||
| b03ca0d4e2 | |||
| 25aaea3d31 | |||
| cae7a139bc | |||
| 5ea62ab05f | |||
| faadc05c88 | |||
| 3e253c589e | |||
| 0a0fc09533 | |||
| 5aa0413fea | |||
| 6f4c199629 | |||
| ed8e1f4654 | |||
| 3b0195e045 | |||
| 143a776373 | |||
| 76b318e990 | |||
| c0ab35c3c2 |
@@ -64,6 +64,7 @@
|
|||||||
"@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:*",
|
||||||
@@ -6332,6 +6333,8 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# 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,6 +45,10 @@ 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,8 +2,15 @@ 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"
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ const projectID = "proj_context_resize_regression"
|
|||||||
const sessionID = "ses_context_resize_regression"
|
const sessionID = "ses_context_resize_regression"
|
||||||
const title = "Context resize regression"
|
const title = "Context resize regression"
|
||||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||||
const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"]
|
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
|
||||||
const followingTextID = `${id("msg_assistant", 10)}:text:0`
|
const followingTextID = "prt_0104_text"
|
||||||
|
|
||||||
type Message = {
|
type Message = {
|
||||||
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||||
@@ -263,7 +263,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
|
|||||||
),
|
),
|
||||||
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
|
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
|
||||||
{
|
{
|
||||||
id: "prt_0104_text",
|
id: followingTextID,
|
||||||
sessionID,
|
sessionID,
|
||||||
messageID: assistantID,
|
messageID: assistantID,
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -295,7 +295,7 @@ function contextTool(
|
|||||||
sessionID,
|
sessionID,
|
||||||
messageID,
|
messageID,
|
||||||
type: "tool",
|
type: "tool",
|
||||||
callID: partID,
|
callID: `call_${partID}`,
|
||||||
tool,
|
tool,
|
||||||
state: {
|
state: {
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -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 opening without replaying it across session tabs", async ({ page }) => {
|
test("animates todo lifecycle 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,8 +86,28 @@ test("animates todo opening 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")).toBeNull()
|
expect(connection.searchParams.get("ticket")).toBe("e2e-ticket")
|
||||||
await writeProbe(page)
|
await writeProbe(page)
|
||||||
|
|
||||||
await switchTab(page, titleB)
|
await switchTab(page, titleB)
|
||||||
|
|||||||
@@ -80,8 +80,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (path === "/global/health")
|
if (path === "/global/health")
|
||||||
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
|
return config.protocol === "v2" ? json(route, {}) : json(route, { healthy: true })
|
||||||
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
if (path === "/api/health" && config.protocol === "v2")
|
||||||
|
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: "/api/event"
|
path: "/global/event" | "/event" | "/api/event"
|
||||||
headers: Record<string, string>
|
headers: Record<string, string>
|
||||||
openedAt: number
|
openedAt: number
|
||||||
endedAt?: number
|
endedAt?: number
|
||||||
@@ -174,7 +174,10 @@ 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 (url.origin !== server || url.pathname !== "/api/event")
|
if (
|
||||||
|
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
|
||||||
@@ -190,9 +193,18 @@ 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`))
|
||||||
controller.enqueue(
|
if (url.pathname === "/api/event")
|
||||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
controller.enqueue(
|
||||||
)
|
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||||
|
)
|
||||||
|
if (url.pathname === "/global/event")
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(
|
||||||
|
frame({
|
||||||
|
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
request.signal.addEventListener(
|
request.signal.addEventListener(
|
||||||
"abort",
|
"abort",
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"@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,6 +238,26 @@ 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.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
load: (search, signal) => serverSDK.currentApi.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.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
load: (search, signal) => serverCtx.sdk.currentApi.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()
|
||||||
.api.integration.get({
|
.currentApi.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()
|
||||||
.api.integration.oauth.connect({
|
.currentApi.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().api.integration.connect.key({
|
await serverSDK().currentApi.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()
|
||||||
.api.integration.oauth.complete({
|
.currentApi.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()
|
||||||
.api.integration.oauth.status({
|
.currentApi.integration.oauth.status({
|
||||||
integrationID: props.provider,
|
integrationID: props.provider,
|
||||||
attemptID: authorization.attemptID,
|
attemptID: authorization.attemptID,
|
||||||
location: location(),
|
location: location(),
|
||||||
|
|||||||
@@ -130,9 +130,26 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const saveMutation = useMutation(() => ({
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>): Promise<typeof result> => {
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||||
// TODO: Restore custom providers when V2 exposes config and arbitrary credential APIs.
|
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
|
||||||
throw new Error(`Custom provider ${result.providerID} is unavailable`)
|
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||||
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
|
if (result.key) {
|
||||||
|
await serverSDK().legacy.auth.set({
|
||||||
|
providerID: result.providerID,
|
||||||
|
auth: {
|
||||||
|
type: "api",
|
||||||
|
key: result.key,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await serverSync().updateConfig({
|
||||||
|
provider: { [result.providerID]: result.config },
|
||||||
|
disabled_providers: nextDisabled,
|
||||||
|
})
|
||||||
|
return result
|
||||||
},
|
},
|
||||||
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.supported || model.save.isPending}>
|
<ButtonV2 type="submit" variant="contrast" disabled={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.supported || model.save.isPending}>
|
<Button type="submit" variant="primary" size="large" disabled={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()
|
||||||
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
|
.currentApi.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,21 +66,23 @@ 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(
|
||||||
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
() =>
|
async (): Promise<Path | undefined> => {
|
||||||
sdk.api.location
|
if ((await sdk.protocol) === "v1")
|
||||||
|
return sdk.legacy.path.get().catch(() => undefined)
|
||||||
|
return sdk.api.location
|
||||||
.get()
|
.get()
|
||||||
.then(
|
.then((location) => ({
|
||||||
(location): Path => ({
|
state: "",
|
||||||
state: "",
|
config: "",
|
||||||
config: "",
|
worktree: location.project.directory,
|
||||||
worktree: location.project.directory,
|
directory: location.directory,
|
||||||
directory: location.directory,
|
home: "",
|
||||||
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 || "")
|
||||||
@@ -102,7 +104,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.api.file
|
const files = await sdk.currentApi.file
|
||||||
.find({
|
.find({
|
||||||
location: { directory: base },
|
location: { directory: base },
|
||||||
query: pickerFileSearchQuery(base, value, home()),
|
query: pickerFileSearchQuery(base, value, home()),
|
||||||
@@ -132,7 +134,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.api.file
|
return sdk.currentApi.file
|
||||||
.list({ location: { directory: absolute } })
|
.list({ location: { directory: absolute } })
|
||||||
.then((result) =>
|
.then((result) =>
|
||||||
result.data.map((entry) => ({
|
result.data.map((entry) => ({
|
||||||
|
|||||||
@@ -57,21 +57,23 @@ 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(
|
||||||
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
() =>
|
async (): Promise<Path | undefined> => {
|
||||||
sdk.api.location
|
if ((await sdk.protocol) === "v1")
|
||||||
|
return sdk.legacy.path.get().catch(() => undefined)
|
||||||
|
return sdk.api.location
|
||||||
.get()
|
.get()
|
||||||
.then(
|
.then((location) => ({
|
||||||
(location): Path => ({
|
state: "",
|
||||||
state: "",
|
config: "",
|
||||||
config: "",
|
worktree: location.project.directory,
|
||||||
worktree: location.project.directory,
|
directory: location.directory,
|
||||||
directory: location.directory,
|
home: "",
|
||||||
home: "",
|
}))
|
||||||
}),
|
.catch(() => undefined)
|
||||||
)
|
},
|
||||||
.catch(() => undefined),
|
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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"
|
||||||
@@ -263,6 +264,13 @@ 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) {
|
||||||
@@ -307,6 +315,13 @@ 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 {
|
||||||
@@ -345,7 +360,9 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
|
|
||||||
const sortedItems = createMemo(() => {
|
const sortedItems = createMemo(() => {
|
||||||
const raw = items()
|
const raw = items()
|
||||||
const list = raw
|
const list = settings.general.newLayoutDesigns()
|
||||||
|
? 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 = {
|
||||||
api: {
|
currentApi: {
|
||||||
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 = {
|
||||||
api: {
|
currentApi: {
|
||||||
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.api.file
|
const request = args.sdk.currentApi.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.api.file
|
const results = await args.sdk.currentApi.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,7 +9,6 @@ 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))
|
||||||
@@ -72,9 +71,23 @@ 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") {
|
||||||
// TODO: Restore project edits when the V2 client exposes a project update API.
|
if ((await serverCtx().sdk.protocol) !== "v1") return
|
||||||
// await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands })
|
const project = await serverCtx()
|
||||||
throw new Error(`Project ${props.project.id} cannot be updated`)
|
.sdk.legacy.project.update({
|
||||||
|
projectID: props.project.id,
|
||||||
|
directory: props.project.worktree,
|
||||||
|
name,
|
||||||
|
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
|
commands: { start },
|
||||||
|
})
|
||||||
|
.then((result) => result.data)
|
||||||
|
if (!project) return
|
||||||
|
serverCtx().sync.set("project", (items) =>
|
||||||
|
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
|
||||||
|
)
|
||||||
|
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||||
|
dialog.close()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
serverCtx().sync.project.meta(props.project.worktree, {
|
serverCtx().sync.project.meta(props.project.worktree, {
|
||||||
@@ -88,7 +101,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
|||||||
|
|
||||||
function submit(event: SubmitEvent) {
|
function submit(event: SubmitEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!supported || save.isPending) return
|
if (save.isPending) return
|
||||||
save.mutate()
|
save.mutate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +111,6 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
|||||||
folderName,
|
folderName,
|
||||||
defaultName,
|
defaultName,
|
||||||
save,
|
save,
|
||||||
supported,
|
|
||||||
submit,
|
submit,
|
||||||
drop,
|
drop,
|
||||||
dragOver,
|
dragOver,
|
||||||
|
|||||||
@@ -31,12 +31,6 @@ 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
|
||||||
|
|
||||||
@@ -99,22 +93,10 @@ 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)
|
||||||
},
|
},
|
||||||
@@ -143,6 +125,13 @@ 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,
|
||||||
@@ -208,8 +197,13 @@ 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
|
||||||
},
|
},
|
||||||
@@ -297,9 +291,6 @@ 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 = {}
|
||||||
@@ -457,17 +448,13 @@ 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("switches the selected agent and model before prompting", async () => {
|
test("includes the selected variant on optimistic prompts", async () => {
|
||||||
params = { id: "session-1" }
|
params = { id: "session-1" }
|
||||||
variant = "high"
|
variant = "high"
|
||||||
|
|
||||||
const submit = createPromptSubmit({
|
const submit = createPromptSubmit({
|
||||||
prompt,
|
prompt,
|
||||||
info: () => ({
|
info: () => ({ id: "session-1" }),
|
||||||
id: "session-1",
|
|
||||||
agent: "old-agent",
|
|
||||||
model: { id: "old-model", providerID: "old-provider" },
|
|
||||||
}),
|
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
autoAccept: () => false,
|
autoAccept: () => false,
|
||||||
@@ -496,14 +483,6 @@ 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,7 +15,6 @@ 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"
|
||||||
@@ -23,6 +22,7 @@ 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,10 +42,9 @@ export type FollowupDraft = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FollowupSendInput = {
|
type FollowupSendInput = {
|
||||||
api: DirectorySDK["api"]["session"]
|
api: DirectorySDK["currentApi"]["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
|
||||||
@@ -158,25 +157,6 @@ 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,
|
||||||
@@ -217,9 +197,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
|
|
||||||
type PromptSubmitInput = {
|
type PromptSubmitInput = {
|
||||||
prompt: ReturnType<typeof usePrompt>
|
prompt: ReturnType<typeof usePrompt>
|
||||||
info: Accessor<
|
info: Accessor<{ id: string } | undefined>
|
||||||
{ 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>
|
||||||
@@ -283,7 +261,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
return sdk()
|
return sdk()
|
||||||
.api.session.interrupt({ sessionID })
|
.currentApi.session.interrupt({ sessionID })
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,10 +345,11 @@ 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()
|
||||||
.api.projectCopy.create({
|
.currentApi.projectCopy.create({
|
||||||
projectID: sync().data.project,
|
projectID: sync().data.project,
|
||||||
strategy: "git_worktree",
|
strategy: "git_worktree",
|
||||||
directory: getDirectory(projectDirectory),
|
directory: getDirectory(projectDirectory),
|
||||||
@@ -383,6 +362,7 @@ 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
|
||||||
@@ -402,7 +382,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()
|
||||||
.api.session.create({
|
.currentApi.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 },
|
||||||
@@ -493,7 +473,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
clearInput()
|
clearInput()
|
||||||
const eventID = Event.ID.create()
|
const eventID = Event.ID.create()
|
||||||
sdk()
|
sdk()
|
||||||
.api.session.shell({
|
.currentApi.session.shell({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
id: eventID,
|
id: eventID,
|
||||||
command: text,
|
command: text,
|
||||||
@@ -517,7 +497,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()
|
||||||
.api.session.command({
|
.currentApi.session.command({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
id: messageID,
|
id: messageID,
|
||||||
command: commandName,
|
command: commandName,
|
||||||
@@ -614,10 +594,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void sendFollowupDraft({
|
void sendFollowupDraft({
|
||||||
api: sdk().api.session,
|
api: sdk().currentApi.session,
|
||||||
sync: sync(),
|
sync: sync(),
|
||||||
serverSync: serverSync(),
|
serverSync: serverSync(),
|
||||||
session: () => input.info() ?? session,
|
|
||||||
draft,
|
draft,
|
||||||
messageID,
|
messageID,
|
||||||
optimisticBusy: sessionDirectory === projectDirectory,
|
optimisticBusy: sessionDirectory === projectDirectory,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||||
import { useUpdaterAction } from "./updater-action"
|
import { useUpdaterAction } from "./updater-action"
|
||||||
import {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
@@ -125,12 +125,11 @@ export const SettingsGeneral: Component = () => {
|
|||||||
|
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
async () => {
|
() => (protocol() === "v1" ? serverSdk() : undefined),
|
||||||
// TODO: Restore executable shell discovery; V2 shell.list only lists shell processes.
|
(sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
|
||||||
return [] as ShellOption[]
|
|
||||||
},
|
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -321,13 +320,13 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
<Show when={protocol() === "v1"}>
|
||||||
title={language.t("settings.general.row.shell.title")}
|
<SettingsRow
|
||||||
description={language.t("settings.general.row.shell.description")}
|
title={language.t("settings.general.row.shell.title")}
|
||||||
>
|
description={language.t("settings.general.row.shell.description")}
|
||||||
|
>
|
||||||
<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}
|
||||||
@@ -335,15 +334,15 @@ export const SettingsGeneral: Component = () => {
|
|||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
if (option.value === currentShell()) return
|
if (option.value === currentShell()) return
|
||||||
// TODO: Restore config writes when the V2 client exposes a config API.
|
serverSync().updateConfig({ shell: option.value })
|
||||||
// void serverSync().updateConfig({ shell: option.value })
|
|
||||||
}}
|
}}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
triggerVariant="settings"
|
triggerVariant="settings"
|
||||||
triggerStyle={{ "min-width": "180px" }}
|
triggerStyle={{ "min-width": "180px" }}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||||
|
|||||||
@@ -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 { useServerSDK } from "@/context/server-sdk"
|
import { useServerProtocol, 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,6 +39,7 @@ 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 })
|
||||||
@@ -83,7 +84,8 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
|||||||
return language.t("settings.providers.tag.other")
|
return language.t("settings.providers.tag.other")
|
||||||
}
|
}
|
||||||
|
|
||||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
|
const canDisconnect = (item: ProviderItem) =>
|
||||||
|
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
|
||||||
|
|
||||||
@@ -96,7 +98,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disableProvider = async (providerID: string, name: string) => {
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
return
|
if (protocol() !== "v1") 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)
|
||||||
@@ -119,13 +121,18 @@ 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().legacy.auth.remove({ providerID }).catch(() => undefined)
|
||||||
|
await disableProvider(providerID, name)
|
||||||
|
return
|
||||||
|
}
|
||||||
await serverSDK()
|
await serverSDK()
|
||||||
.api.integration.get({ integrationID: providerID })
|
.currentApi.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().api.credential.remove({ credentialID: credential.id })),
|
credentials.map((credential) => serverSDK().currentApi.credential.remove({ credentialID: credential.id })),
|
||||||
)
|
)
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
@@ -216,7 +223,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<Show when={false}>
|
<Show when={protocol() === "v1"}>
|
||||||
<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,6 +10,7 @@ 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 { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||||
import { useUpdaterAction } from "../updater-action"
|
import { useUpdaterAction } from "../updater-action"
|
||||||
import {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
@@ -90,6 +91,8 @@ 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 protocol = useServerProtocol()
|
||||||
const mobile = createMediaQuery("(max-width: 767px)")
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
|
|
||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
@@ -120,11 +123,8 @@ export const SettingsGeneralV2: Component<{
|
|||||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
async () => {
|
() => (protocol() === "v1" ? serverSdk() : undefined),
|
||||||
// TODO: Restore executable shell discovery when the V2 client exposes it.
|
(sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
|
||||||
// return (await sdk.api.pty.shells()).data
|
|
||||||
return [] as ShellOption[]
|
|
||||||
},
|
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -279,10 +279,11 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
|
|
||||||
<SettingsRowV2
|
<Show when={protocol() === "v1"}>
|
||||||
title={language.t("settings.general.row.shell.title")}
|
<SettingsRowV2
|
||||||
description={language.t("settings.general.row.shell.description")}
|
title={language.t("settings.general.row.shell.title")}
|
||||||
>
|
description={language.t("settings.general.row.shell.description")}
|
||||||
|
>
|
||||||
<SelectV2
|
<SelectV2
|
||||||
appearance="inline"
|
appearance="inline"
|
||||||
data-action="settings-shell"
|
data-action="settings-shell"
|
||||||
@@ -298,7 +299,8 @@ export const SettingsGeneralV2: Component<{
|
|||||||
serverSync().updateConfig({ shell: option.value })
|
serverSync().updateConfig({ shell: option.value })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<SettingsRowV2
|
<SettingsRowV2
|
||||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||||
|
|||||||
@@ -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 { useServerSDK } from "@/context/server-sdk"
|
import { useServerProtocol, 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,6 +36,7 @@ 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 })
|
||||||
@@ -80,7 +81,8 @@ export const SettingsProvidersV2: Component<{
|
|||||||
return language.t("settings.providers.tag.other")
|
return language.t("settings.providers.tag.other")
|
||||||
}
|
}
|
||||||
|
|
||||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
|
const canDisconnect = (item: ProviderItem) =>
|
||||||
|
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
|
||||||
|
|
||||||
@@ -93,7 +95,7 @@ export const SettingsProvidersV2: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disableProvider = async (providerID: string, name: string) => {
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
return
|
if (protocol() !== "v1") 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)
|
||||||
@@ -116,15 +118,20 @@ export const SettingsProvidersV2: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
const disconnect = async (providerID: string, name: string) => {
|
||||||
|
if (isConfigCustom(providerID)) {
|
||||||
|
await serverSdk().legacy.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()
|
||||||
.api.integration.get({ integrationID: providerID, location })
|
.currentApi.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().api.credential.remove({ credentialID: credential.id, location }),
|
serverSdk().currentApi.credential.remove({ credentialID: credential.id, location }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
showToast({
|
showToast({
|
||||||
@@ -222,7 +229,7 @@ export const SettingsProvidersV2: Component<{
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<Show when={false}>
|
<Show when={protocol() === "v1"}>
|
||||||
<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, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
|
import { type Accessor, createEffect, createMemo, 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 { useSDK } from "@/context/sdk"
|
import { useServerProtocol } from "@/context/server-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,7 +251,6 @@ 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()
|
||||||
@@ -259,6 +258,7 @@ 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,7 +279,9 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
dialogRun += 1
|
dialogRun += 1
|
||||||
})
|
})
|
||||||
const sortedServers = createMemo(() => {
|
const sortedServers = createMemo(() => {
|
||||||
const list = global.servers.list()
|
const list = settings.general.newLayoutDesigns()
|
||||||
|
? 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()
|
||||||
@@ -289,11 +291,9 @@ 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 [pluginList] = createResource(
|
const plugins = createMemo(() =>
|
||||||
() => (props.shown() ? sdk().directory : undefined),
|
(sync().data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
|
||||||
(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"))
|
||||||
|
|
||||||
@@ -318,11 +318,13 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
||||||
{language.t("status.popover.tab.mcp")}
|
{language.t("status.popover.tab.mcp")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
<Show when={protocol() === "v1"}>
|
||||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
||||||
{language.t("status.popover.tab.lsp")}
|
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||||
</Tabs.Trigger>
|
{language.t("status.popover.tab.lsp")}
|
||||||
<Show when={true}>
|
</Tabs.Trigger>
|
||||||
|
</Show>
|
||||||
|
<Show when={protocol() === "v1"}>
|
||||||
<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")}
|
||||||
@@ -459,7 +461,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
<Tabs.Content value="lsp">
|
<Show when={protocol() === "v1"}>
|
||||||
|
<Tabs.Content value="lsp">
|
||||||
<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">
|
||||||
<Show
|
<Show
|
||||||
@@ -485,9 +488,10 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={true}>
|
<Show when={protocol() === "v1"}>
|
||||||
<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,11 +11,13 @@ 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+`"
|
||||||
@@ -174,8 +176,13 @@ 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",
|
||||||
@@ -234,7 +241,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
const pushSize = async (cols: number, rows: number) => {
|
const pushSize = async (cols: number, rows: number) => {
|
||||||
return sdk()
|
return sdk()
|
||||||
.api.pty.update({
|
.currentApi.pty.update({
|
||||||
ptyID: id,
|
ptyID: id,
|
||||||
location: { directory },
|
location: { directory },
|
||||||
size: { cols, rows },
|
size: { cols, rows },
|
||||||
@@ -516,7 +523,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
const gone = async () => {
|
const gone = async () => {
|
||||||
return sdk()
|
return sdk()
|
||||||
.api.pty.get({ ptyID: id, location: { directory } })
|
.currentApi.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
|
||||||
@@ -526,8 +533,23 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const connectToken = async () => {
|
const connectToken = async () => {
|
||||||
// TODO: Add PTY tickets when the V2 client exposes a connect-token API.
|
const endpoint = new URL(`/api/pty/${encodeURIComponent(id)}/connect-token`, url)
|
||||||
return undefined
|
endpoint.searchParams.set("location[directory]", directory)
|
||||||
|
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,6 +4,7 @@ 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"
|
||||||
@@ -54,7 +55,10 @@ 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(() => props.session()?.title ?? props.fallbackTitle)
|
const title = createMemo(() => {
|
||||||
|
const session = props.session()
|
||||||
|
return session ? displayLabel(session) : props.fallbackTitle
|
||||||
|
})
|
||||||
|
|
||||||
const projectName = createMemo(() => {
|
const projectName = createMemo(() => {
|
||||||
const session = props.session()
|
const session = props.session()
|
||||||
@@ -302,7 +306,7 @@ export function TabNavItem(props: {
|
|||||||
}}
|
}}
|
||||||
data={{
|
data={{
|
||||||
projectName: projectName(),
|
projectName: projectName(),
|
||||||
title: props.session()?.title,
|
title: 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.api.session.rename({ sessionID: value.id, title })
|
await ctx.sdk.currentApi.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.api.session
|
sdk.currentApi.session
|
||||||
.get({ sessionID: route.sessionId })
|
.get({ sessionID: route.sessionId })
|
||||||
.then(normalizeSessionInfo)
|
.then(normalizeSessionInfo)
|
||||||
.catch(() => {}),
|
.catch(() => {}),
|
||||||
|
|||||||
@@ -248,11 +248,6 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
|
|||||||
return IS_MAC ? parts.join("") : parts.join("+")
|
return IS_MAC ? parts.join("") : parts.join("+")
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeybindV2 takes an array instead of a string
|
|
||||||
export function formatKeybindKeys(config: string, t?: (key: KeyLabel) => string): string[] {
|
|
||||||
return formatKeybindParts(config, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEditableTarget(target: EventTarget | null) {
|
function isEditableTarget(target: EventTarget | null) {
|
||||||
if (!(target instanceof HTMLElement)) return false
|
if (!(target instanceof HTMLElement)) return false
|
||||||
if (target.isContentEditable) return true
|
if (target.isContentEditable) return true
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ 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, {
|
||||||
@@ -123,7 +124,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.api.session.list({ directory, limit: store.limit, order: "desc" })
|
const response = await serverSDK.currentApi.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))
|
||||||
@@ -133,8 +134,14 @@ 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) => {
|
||||||
// TODO: Restore archiving when the V2 client exposes a session archive API.
|
await serverSDK.legacy.session.archive(sessionID, directory)
|
||||||
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()
|
||||||
.api.file.list({ path: dir, location: { directory: scope() } })
|
.currentApi.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()
|
||||||
.api.file.read({ path: file, location: { directory } })
|
.currentApi.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()
|
||||||
.api.file.find(
|
.currentApi.file.find(
|
||||||
{
|
{
|
||||||
location: { directory: sdk().directory },
|
location: { directory: sdk().directory },
|
||||||
query,
|
query,
|
||||||
@@ -286,13 +286,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
children: tree.children,
|
children: tree.children,
|
||||||
expand: tree.expandDir,
|
expand: tree.expandDir,
|
||||||
collapse: tree.collapseDir,
|
collapse: tree.collapseDir,
|
||||||
toggle(input: string) {
|
|
||||||
if (tree.dirState(input)?.expanded) {
|
|
||||||
tree.collapseDir(input)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tree.expandDir(input)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
get,
|
get,
|
||||||
load,
|
load,
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
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 { LegacyCapabilities } from "@/utils/server-compat"
|
||||||
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,
|
||||||
@@ -10,19 +14,139 @@ 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,
|
||||||
|
},
|
||||||
|
legacy: { config: { directory: async () => ({}) } } as unknown as LegacyCapabilities,
|
||||||
|
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 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).queryKey]).toEqual(["local", "/repo", "path"])
|
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual([
|
||||||
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
"local",
|
||||||
|
"/repo",
|
||||||
|
"path",
|
||||||
|
])
|
||||||
|
expect([...loadPathQuery(remote, "/repo", location).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"])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -57,21 +181,6 @@ 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,6 +8,7 @@ import type {
|
|||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
Session,
|
Session,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
|
import type { LegacyCapabilities } from "@/utils/server-compat"
|
||||||
import type {
|
import type {
|
||||||
AgentListInput,
|
AgentListInput,
|
||||||
AgentListOutput,
|
AgentListOutput,
|
||||||
@@ -44,6 +45,7 @@ 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 = {
|
||||||
@@ -105,11 +107,11 @@ function showErrors(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadGlobalConfigQuery = (scope: ServerScope) =>
|
export const loadGlobalConfigQuery = (scope: ServerScope, legacy: LegacyCapabilities, enabled = true) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, "config"],
|
queryKey: [scope, "config"],
|
||||||
// TODO: Restore config loading when the V2 client exposes a config API.
|
queryFn: () => retry(() => legacy.config.global()),
|
||||||
queryFn: async (): Promise<Config> => ({}),
|
enabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
type ProjectApi = {
|
type ProjectApi = {
|
||||||
@@ -140,7 +142,9 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
|
legacy: LegacyCapabilities
|
||||||
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
|
||||||
@@ -148,18 +152,22 @@ export async function bootstrapGlobal(input: {
|
|||||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
}) {
|
}) {
|
||||||
|
const protocol = await input.protocol
|
||||||
const slow = [
|
const slow = [
|
||||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
|
protocol === "v1" && (() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.legacy))),
|
||||||
() =>
|
() =>
|
||||||
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.queryClient
|
input.queryClient
|
||||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||||
.then((data) => input.setGlobalStore("project", data)),
|
.then((data) => input.setGlobalStore("project", data)),
|
||||||
]
|
].filter(Boolean) as Array<() => Promise<unknown>>
|
||||||
await runAll(slow)
|
await runAll(slow)
|
||||||
// showErrors({
|
// showErrors({
|
||||||
// errors: errors(),
|
// errors: errors(),
|
||||||
@@ -271,7 +279,7 @@ export const loadPathQuery = (
|
|||||||
queryOptions<Path>({
|
queryOptions<Path>({
|
||||||
queryKey: [scope, directory, "path"],
|
queryKey: [scope, directory, "path"],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get(directory ? { location: { directory } } : undefined).then((location) => ({
|
retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({
|
||||||
state: "",
|
state: "",
|
||||||
config: "",
|
config: "",
|
||||||
worktree: location.project.directory,
|
worktree: location.project.directory,
|
||||||
@@ -296,6 +304,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
directory: string
|
directory: string
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
mcp: boolean
|
mcp: boolean
|
||||||
|
legacy: LegacyCapabilities
|
||||||
api: CatalogApi & {
|
api: CatalogApi & {
|
||||||
readonly agent: AgentListApi
|
readonly agent: AgentListApi
|
||||||
readonly command: CommandListApi
|
readonly command: CommandListApi
|
||||||
@@ -321,6 +330,7 @@ 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)
|
||||||
@@ -342,6 +352,13 @@ 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)),
|
||||||
|
(await input.protocol) === "v1" &&
|
||||||
|
(() =>
|
||||||
|
retry(() =>
|
||||||
|
input.legacy.config
|
||||||
|
.directory(input.directory)
|
||||||
|
.then((config) => input.setStore("config", reconcile(config, { merge: false }))),
|
||||||
|
)),
|
||||||
!seededProject &&
|
!seededProject &&
|
||||||
(() =>
|
(() =>
|
||||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||||
@@ -350,7 +367,9 @@ export async function bootstrapDirectory(input: {
|
|||||||
!seededPath &&
|
!seededPath &&
|
||||||
(() =>
|
(() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location))
|
.ensureQueryData(
|
||||||
|
loadPathQuery(input.scope, input.directory, input.api.location),
|
||||||
|
)
|
||||||
.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)
|
||||||
|
|||||||
@@ -191,7 +191,10 @@ export function createChildStoreManager(input: {
|
|||||||
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
||||||
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
||||||
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
||||||
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
|
const lspQuery = useQuery(() => {
|
||||||
|
const options = input.queryOptions.lsp(key)
|
||||||
|
return { ...options, enabled: options.enabled !== false && instanceQueriesEnabled() }
|
||||||
|
})
|
||||||
const providerQuery = useQuery(() => ({
|
const providerQuery = useQuery(() => ({
|
||||||
...input.queryOptions.providers(key),
|
...input.queryOptions.providers(key),
|
||||||
enabled: instanceQueriesEnabled(),
|
enabled: instanceQueriesEnabled(),
|
||||||
|
|||||||
@@ -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: [session({ id: "root" })], cursor: {} }
|
return { data: { data: [session({ id: "root" })], cursor: {} } }
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.sessions).toHaveLength(1)
|
expect(result.sessions).toHaveLength(1)
|
||||||
@@ -48,13 +48,15 @@ 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: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
|
data: {
|
||||||
session({ id: `page-1-${index}` }),
|
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
|
||||||
),
|
session({ id: `page-1-${index}` }),
|
||||||
cursor: { next: "next-page" },
|
),
|
||||||
|
cursor: { next: "next-page" },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { data: [session({ id: "page-2" })], cursor: {} }
|
return { data: { data: [session({ id: "page-2" })], cursor: {} } }
|
||||||
},
|
},
|
||||||
0,
|
0,
|
||||||
controller.signal,
|
controller.signal,
|
||||||
@@ -143,8 +145,10 @@ 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", () => {
|
test("refetches after reconnect, disposal, and session moves", () => {
|
||||||
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,5 +1,4 @@
|
|||||||
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"
|
||||||
@@ -22,11 +21,13 @@ 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<V2SessionListResponse>,
|
) => Promise<HomeSessionPage>,
|
||||||
eventSequence = 0,
|
eventSequence = 0,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
@@ -42,7 +43,7 @@ export async function loadHomeSessionIndex(
|
|||||||
},
|
},
|
||||||
{ signal },
|
{ signal },
|
||||||
)
|
)
|
||||||
const page = response
|
const page = response.data!
|
||||||
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 }
|
||||||
@@ -74,7 +75,10 @@ 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 { connected, refetch: false }
|
return {
|
||||||
|
connected,
|
||||||
|
refetch: event === "global.disposed" || event === "session.next.moved",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
|
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
|
||||||
@@ -141,7 +145,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)
|
||||||
}
|
}
|
||||||
@@ -161,7 +165,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: withTimestampedFallback(session),
|
title: session.title,
|
||||||
agent: session.agent,
|
agent: session.agent,
|
||||||
model: session.model,
|
model: session.model,
|
||||||
version: "",
|
version: "",
|
||||||
|
|||||||
@@ -58,7 +58,20 @@ export function normalizeProviderList(
|
|||||||
defaultModel?: ModelDefaultOutput["data"],
|
defaultModel?: ModelDefaultOutput["data"],
|
||||||
): NormalizedProviderListResponse {
|
): NormalizedProviderListResponse {
|
||||||
if (!Array.isArray(providers)) {
|
if (!Array.isArray(providers)) {
|
||||||
return providers
|
return {
|
||||||
|
...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>()
|
||||||
|
|
||||||
@@ -140,18 +153,6 @@ export function normalizeProviderList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeProject(project: Project) {
|
|
||||||
if (!project.icon?.url && !project.icon?.override) return project
|
|
||||||
return {
|
|
||||||
...project,
|
|
||||||
icon: {
|
|
||||||
...project.icon,
|
|
||||||
url: undefined,
|
|
||||||
override: undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
|
|||||||
@@ -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,8 +572,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const projectID = project.id
|
const projectID = project.id
|
||||||
void (async () => {
|
void (async () => {
|
||||||
// TODO: Restore project color updates when the V2 client exposes a project update API.
|
const sdk = serverSdk()
|
||||||
void projectID
|
if ((await sdk.protocol) !== "v1") return
|
||||||
|
return sdk.legacy.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)
|
||||||
})
|
})
|
||||||
@@ -744,9 +753,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
mobileSidebar: {
|
mobileSidebar: {
|
||||||
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
|
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
|
||||||
show() {
|
|
||||||
setStore("mobileSidebar", "opened", true)
|
|
||||||
},
|
|
||||||
hide() {
|
hide() {
|
||||||
setStore("mobileSidebar", "opened", false)
|
setStore("mobileSidebar", "opened", false)
|
||||||
},
|
},
|
||||||
@@ -952,33 +958,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
if (current.reviewOpen.includes(path)) return
|
if (current.reviewOpen.includes(path)) return
|
||||||
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
|
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
|
||||||
},
|
},
|
||||||
closePath(path: string) {
|
|
||||||
const session = key()
|
|
||||||
const current = store.sessionView[session]?.reviewOpen
|
|
||||||
if (!current) return
|
|
||||||
|
|
||||||
const index = current.indexOf(path)
|
|
||||||
if (index === -1) return
|
|
||||||
setStore(
|
|
||||||
"sessionView",
|
|
||||||
session,
|
|
||||||
"reviewOpen",
|
|
||||||
produce((draft) => {
|
|
||||||
if (!draft) return
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
togglePath(path: string) {
|
|
||||||
const session = key()
|
|
||||||
const current = store.sessionView[session]?.reviewOpen
|
|
||||||
if (!current || !current.includes(path)) {
|
|
||||||
this.openPath(path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.closePath(path)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ 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
|
||||||
@@ -249,6 +250,7 @@ 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)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
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"
|
|
||||||
|
|
||||||
const context = createSimpleContext({
|
export type { DirectorySDK }
|
||||||
|
|
||||||
|
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> }) => {
|
||||||
@@ -14,6 +15,3 @@ const context = createSimpleContext({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const useSDK: () => Accessor<DirectorySDK> = context.use
|
|
||||||
export const SDKProvider = context.provider
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
|
||||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||||
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||||
|
import type { Event } from "@/types"
|
||||||
|
|
||||||
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", () => {
|
||||||
@@ -15,7 +16,7 @@ describe("resumeStreamAfterPageShow", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("adaptServerEvent", () => {
|
describe("adaptServerEvent", () => {
|
||||||
test("preserves current events while adapting permission requests for existing consumers", () => {
|
test("preserves V2 events while adapting permission requests for existing consumers", () => {
|
||||||
const current = {
|
const current = {
|
||||||
id: "evt_1",
|
id: "evt_1",
|
||||||
created: 1,
|
created: 1,
|
||||||
@@ -30,7 +31,6 @@ 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,24 +44,42 @@ describe("adaptServerEvent", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("current event buffering", () => {
|
describe("coalesceServerEvents", () => {
|
||||||
const delta = (id: string, value: string, ordinal = 0) => ({
|
const delta = (value: string, field = "text", partID = "part") => ({
|
||||||
directory: "/repo",
|
directory: "/repo",
|
||||||
payload: adaptServerEvent({
|
payload: {
|
||||||
id,
|
type: "message.part.delta",
|
||||||
created: 1,
|
properties: { messageID: "msg", partID, field, delta: value },
|
||||||
type: "session.text.delta",
|
} as Event,
|
||||||
location: { directory: "/repo" },
|
|
||||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
|
||||||
} as OpenCodeEvent),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("merges adjacent text deltas for the same message and ordinal", () => {
|
test("merges adjacent deltas for the same field", () => {
|
||||||
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
const first = delta("hello ")
|
||||||
|
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", () => {
|
||||||
@@ -84,20 +102,127 @@ describe("current event buffering", () => {
|
|||||||
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 boundaries between distinct delta streams", () => {
|
test("preserves event boundaries and distinct fields", () => {
|
||||||
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
const status = {
|
||||||
|
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(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual([
|
expect(result.map((event) => event.payload.type)).toEqual([
|
||||||
"evt_1",
|
"message.part.delta",
|
||||||
"evt_2",
|
"message.part.delta",
|
||||||
"evt_3",
|
"session.status",
|
||||||
|
"message.part.delta",
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves current event order when enqueuing", () => {
|
test("preserves event ID order across interleaved deltas", () => {
|
||||||
const events: Parameters<typeof enqueueServerEvent>[0] = []
|
const first = delta("a")
|
||||||
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
|
const other = delta("b", "text", "other")
|
||||||
|
const last = delta("c")
|
||||||
|
first.payload.id = "1"
|
||||||
|
other.payload.id = "2"
|
||||||
|
last.payload.id = "3"
|
||||||
|
|
||||||
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
|
const result = coalesceServerEvents([first, other, last])
|
||||||
|
|
||||||
|
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,14 +3,22 @@ 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, onCleanup, onMount } from "solid-js"
|
import { type Accessor, batch, createMemo, createResource, onCleanup, onMount } from "solid-js"
|
||||||
import { createApiForServer, type ServerApi } from "@/utils/server"
|
import { createApiForServer, createSdkForServer, 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,
|
||||||
|
createLegacyCapabilities,
|
||||||
|
type CompatibleApi,
|
||||||
|
type LegacyCapabilities,
|
||||||
|
} 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"
|
||||||
@@ -46,7 +54,22 @@ 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
|
||||||
}
|
}
|
||||||
@@ -82,7 +105,33 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
|||||||
output.push(event)
|
output.push(event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
output.push(event)
|
if (event.payload.type !== "message.part.delta") {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -117,13 +166,21 @@ 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
|
||||||
api: ServerApi
|
client: ReturnType<typeof createSdkForServer>
|
||||||
|
api: CompatibleApi
|
||||||
|
legacy: LegacyCapabilities
|
||||||
|
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 {
|
||||||
@@ -142,6 +199,11 @@ 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
|
||||||
}>()
|
}>()
|
||||||
@@ -259,23 +321,66 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
flush()
|
flush()
|
||||||
})
|
})
|
||||||
|
|
||||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
const sdk = createSdkForServer({
|
||||||
|
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 })
|
||||||
|
const capabilities = createLegacyCapabilities({ protocol, current: currentApi, legacy })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
scope,
|
scope,
|
||||||
|
protocol,
|
||||||
|
protocolKind,
|
||||||
url: server.http.url,
|
url: server.http.url,
|
||||||
|
client: sdk,
|
||||||
api,
|
api,
|
||||||
|
legacy: capabilities,
|
||||||
|
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
|
||||||
|
legacy: LegacyCapabilities
|
||||||
|
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
|
||||||
|
readonly url: string
|
||||||
|
createClient: ServerSDKBase["createClient"]
|
||||||
|
}
|
||||||
|
|
||||||
export type ServerSDK = ServerSDKBase & {
|
export type ServerSDK = ServerSDKBase & {
|
||||||
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
ensureDirSdkContext: (directory: string) => DirectorySDK
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
|
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
|
||||||
@@ -302,19 +407,17 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
type SDKEventMap = {
|
export function useServerProtocol() {
|
||||||
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
const serverSDK = useServerSDK()
|
||||||
}
|
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) => {
|
||||||
@@ -324,11 +427,28 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): Direc
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
|
protocol: serverSDK.protocol,
|
||||||
directory,
|
directory,
|
||||||
api: serverSDK.api,
|
client,
|
||||||
|
currentApi: serverSDK.currentApi,
|
||||||
|
api: createCompatibleApi({
|
||||||
|
protocol: serverSDK.protocol,
|
||||||
|
current: serverSDK.currentApi,
|
||||||
|
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
||||||
|
directory,
|
||||||
|
}),
|
||||||
|
legacy: createLegacyCapabilities({
|
||||||
|
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,14 +1,8 @@
|
|||||||
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 {
|
import type { OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise"
|
||||||
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"
|
||||||
|
|
||||||
@@ -28,130 +22,19 @@ 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", variant: undefined },
|
model: { providerID: "provider", modelID: "model" },
|
||||||
...input,
|
...input,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -163,24 +46,21 @@ 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: "", root: "" },
|
path: { cwd: "/repo", root: "/repo" },
|
||||||
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 => ({
|
||||||
@@ -194,27 +74,19 @@ 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 () => sessionInfo(session("child", "root")),
|
get: async () => ({ data: session("child", "root") }),
|
||||||
message: async () => {
|
messages: (input: unknown) => {
|
||||||
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 pages[index++]!
|
return responses[index++]
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as { session: SessionApi; message: MessageApi }
|
} as unknown as OpencodeClient
|
||||||
return Object.assign(client, {
|
return Object.assign(client, {
|
||||||
requests,
|
requests,
|
||||||
requested(count: number) {
|
requested(count: number) {
|
||||||
@@ -235,22 +107,19 @@ function rootMessageClient(
|
|||||||
const rootWaiting = new Map<number, () => void>()
|
const rootWaiting = new Map<number, () => void>()
|
||||||
const client = {
|
const client = {
|
||||||
session: {
|
session: {
|
||||||
get: async () => sessionInfo(session("child", "root")),
|
get: async () => ({ data: session("child", "root") }),
|
||||||
message: async (input: unknown) => {
|
messages: (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)
|
||||||
const value = await roots[rootIndex++]!
|
return roots[rootIndex++]
|
||||||
return currentMessages([value.data]).find((message) => message.id === value.data.info.id)!
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
message: {
|
} as unknown as OpencodeClient
|
||||||
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,
|
||||||
@@ -280,19 +149,16 @@ 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 sessionInfo(sessions[id]!)
|
return { data: sessions[id] }
|
||||||
},
|
},
|
||||||
message: async () => {
|
messages: async (input: unknown) => {
|
||||||
throw new Error("Unexpected single message request")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
list: async (input: unknown) => {
|
|
||||||
messages.push(input)
|
messages.push(input)
|
||||||
return currentPage(response())
|
return response()
|
||||||
},
|
},
|
||||||
|
diff: async () => ({ data: [] }),
|
||||||
|
todo: async () => ({ data: [] }),
|
||||||
},
|
},
|
||||||
} as unknown as { session: SessionApi; message: MessageApi }
|
} as unknown as OpencodeClient
|
||||||
return { get, messages, store: createServerSession(client) }
|
return { get, messages, store: createServerSession(client) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,7 +230,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, order: "desc" }])
|
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }])
|
||||||
expect(ctx.store.data.message.root).toEqual([])
|
expect(ctx.store.data.message.root).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -379,19 +245,54 @@ 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({} as SessionApi, messageApi)
|
const store = createServerSession(client, {} 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 () => {
|
||||||
@@ -420,7 +321,7 @@ describe("server session", () => {
|
|||||||
return pages.shift()!
|
return pages.shift()!
|
||||||
},
|
},
|
||||||
} as unknown as MessageApi
|
} as unknown as MessageApi
|
||||||
const store = createServerSession({} as SessionApi, messageApi)
|
const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi)
|
||||||
store.remember(session("root"))
|
store.remember(session("root"))
|
||||||
|
|
||||||
await store.sync("root")
|
await store.sync("root")
|
||||||
@@ -436,14 +337,50 @@ 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"])
|
||||||
})
|
})
|
||||||
|
|
||||||
// V2 messages are ordered projections and do not expose V1 assistant parent IDs.
|
test("indexes V1 messages for the current timeline projection", async () => {
|
||||||
describe.skip("V1 assistant parent projections", () => {
|
const user = userMessage("message-1", { sessionID: "root" })
|
||||||
|
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(assistants.map((info) => ({ info, parts: [] }))),
|
response(
|
||||||
|
assistants.map((info) => ({ info, parts: [] })),
|
||||||
|
"older",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
[singleResponse(user)],
|
[singleResponse(user)],
|
||||||
)
|
)
|
||||||
@@ -451,16 +388,16 @@ describe("server session", () => {
|
|||||||
|
|
||||||
await store.sync("child")
|
await store.sync("child")
|
||||||
|
|
||||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }])
|
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }])
|
||||||
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(false)
|
expect(store.history.more("child")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
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: [] }])], [missing.promise])
|
const client = rootMessageClient([response([{ info: assistant, parts: [] }], "older")], [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)
|
||||||
@@ -470,7 +407,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(false)
|
expect(store.history.more("child")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
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 () => {
|
||||||
@@ -484,7 +421,7 @@ describe("server session", () => {
|
|||||||
{ info: parent, parts: [part] },
|
{ info: parent, parts: [part] },
|
||||||
{ info: assistant, parts: [] },
|
{ info: assistant, parts: [] },
|
||||||
]),
|
]),
|
||||||
response([{ info: assistant, parts: [] }]),
|
response([{ info: assistant, parts: [] }], "older"),
|
||||||
],
|
],
|
||||||
[missing.promise],
|
[missing.promise],
|
||||||
)
|
)
|
||||||
@@ -506,7 +443,10 @@ 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(assistants.map((info) => ({ info, parts: [] }))),
|
response(
|
||||||
|
assistants.map((info) => ({ info, parts: [] })),
|
||||||
|
"older",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
[singleResponse(user)],
|
[singleResponse(user)],
|
||||||
)
|
)
|
||||||
@@ -528,7 +468,10 @@ describe("server session", () => {
|
|||||||
const client = rootMessageClient(
|
const client = rootMessageClient(
|
||||||
[
|
[
|
||||||
response([{ info: unrelated, parts: [] }]),
|
response([{ info: unrelated, parts: [] }]),
|
||||||
response(assistants.map((info) => ({ info, parts: [] }))),
|
response(
|
||||||
|
assistants.map((info) => ({ info, parts: [] })),
|
||||||
|
"older",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
[singleResponse(user)],
|
[singleResponse(user)],
|
||||||
)
|
)
|
||||||
@@ -547,7 +490,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: [] }])],
|
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }], "older")],
|
||||||
[singleResponse(user)],
|
[singleResponse(user)],
|
||||||
)
|
)
|
||||||
const store = createServerSession(client)
|
const store = createServerSession(client)
|
||||||
@@ -565,7 +508,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: [] }])],
|
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }], "older")],
|
||||||
[singleResponse(fresh, [freshPart])],
|
[singleResponse(fresh, [freshPart])],
|
||||||
)
|
)
|
||||||
const store = createServerSession(client)
|
const store = createServerSession(client)
|
||||||
@@ -586,7 +529,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: [] }])],
|
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }], "older")],
|
||||||
[singleResponse(fresh, [refreshed])],
|
[singleResponse(fresh, [refreshed])],
|
||||||
)
|
)
|
||||||
const store = createServerSession(client)
|
const store = createServerSession(client)
|
||||||
@@ -609,7 +552,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: [] }]))
|
pending.resolve(response([{ info: assistant, parts: [] }], "older"))
|
||||||
await loading
|
await loading
|
||||||
|
|
||||||
expect(client.rootRequests).toEqual([])
|
expect(client.rootRequests).toEqual([])
|
||||||
@@ -625,6 +568,7 @@ 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)],
|
||||||
@@ -648,7 +592,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: [] }])],
|
[response([{ info: assistant, parts: [] }], "older")],
|
||||||
[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 })
|
||||||
@@ -668,7 +612,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: [] }])],
|
[response([{ info: assistant, parts: [] }], "older")],
|
||||||
[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 })
|
||||||
@@ -689,7 +633,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] }])],
|
[response([{ info: assistant, parts: [stale] }], "older")],
|
||||||
[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 })
|
||||||
@@ -702,7 +646,6 @@ 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()
|
||||||
@@ -1502,7 +1445,6 @@ 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,9 +10,12 @@ import type {
|
|||||||
SessionStatus,
|
SessionStatus,
|
||||||
Todo,
|
Todo,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
|
import type { LegacyCapabilities } from "@/utils/server-compat"
|
||||||
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"
|
||||||
@@ -30,29 +33,6 @@ 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) =>
|
||||||
@@ -81,6 +61,30 @@ 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>
|
||||||
@@ -179,18 +183,20 @@ 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 }
|
type ServerSessionOptions = {
|
||||||
type ServerSessionApis = { session: SessionApi; message: MessageApi }
|
retry?: typeof retry
|
||||||
|
protocol?: Promise<"v1" | "v2">
|
||||||
|
legacy?: LegacyCapabilities
|
||||||
|
}
|
||||||
|
|
||||||
export function createServerSession(
|
export function createServerSession(
|
||||||
api: SessionApi | ServerSessionApis,
|
client: { session: Pick<LegacyCapabilities["session"], "get" | "messages" | "message"> },
|
||||||
messageApiOrOptions?: MessageApi | ServerSessionOptions,
|
sessionApiOrOptions?: SessionApi | ServerSessionOptions,
|
||||||
|
messageApi?: MessageApi,
|
||||||
currentOptions?: ServerSessionOptions,
|
currentOptions?: ServerSessionOptions,
|
||||||
) {
|
) {
|
||||||
const bundled = "session" in api
|
const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined
|
||||||
const sessionApi = bundled ? api.session : api
|
const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined)
|
||||||
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>,
|
||||||
@@ -245,13 +251,13 @@ export function createServerSession(
|
|||||||
at: {} as Record<string, number | undefined>,
|
at: {} as Record<string, number | undefined>,
|
||||||
})
|
})
|
||||||
|
|
||||||
const indexProjectedMessage = (message: Message) => {
|
const indexLegacyMessage = (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, ...projectMessageSource(message)]),
|
reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +311,12 @@ 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.get({ sessionID }).then(normalizeSessionInfo)
|
const request = sessionApi
|
||||||
|
? 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)
|
||||||
@@ -531,43 +542,72 @@ 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) => {
|
||||||
const request = (cursor?: string) =>
|
if (messageApi && (await options?.protocol) !== "v1") {
|
||||||
(options?.retry ?? retry)(() => {
|
const request = (cursor?: string) =>
|
||||||
onAttempt?.()
|
(options?.retry ?? retry)(() => {
|
||||||
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
|
onAttempt?.()
|
||||||
})
|
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
|
||||||
const first = await request(before)
|
})
|
||||||
const pages = [first]
|
const first = await request(before)
|
||||||
while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) {
|
const pages = [first]
|
||||||
const response = await request(pages.at(-1)!.cursor.next ?? undefined)
|
while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) {
|
||||||
pages.push(response)
|
const response = await request(pages.at(-1)!.cursor.next ?? undefined)
|
||||||
if (!response.data.length) break
|
pages.push(response)
|
||||||
|
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 = pages.at(-1)!
|
const response = await (options?.retry ?? retry)(() => {
|
||||||
const source = pages.flatMap((page) => page.data).toReversed()
|
onAttempt?.()
|
||||||
const normalized = normalizeSessionMessages(sessionID, source)
|
return client.session.messages({ sessionID, limit, before })
|
||||||
|
})
|
||||||
|
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
||||||
return {
|
return {
|
||||||
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
|
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
||||||
part: [...normalized.parts.entries()]
|
part: items.map((item) => ({
|
||||||
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
id: item.info.id,
|
||||||
.sort((a, b) => cmp(a.id, b.id)),
|
part: item.parts.filter((part) => !!part?.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),
|
||||||
projectSource: true,
|
cursor: response.response.headers.get("x-next-cursor") ?? undefined,
|
||||||
cursor: response.cursor.next ?? undefined,
|
complete: !response.response.headers.get("x-next-cursor"),
|
||||||
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 sessionApi.message({ sessionID, messageID })
|
return client.session.message({ sessionID, messageID })
|
||||||
})
|
})
|
||||||
const normalized = normalizeSessionMessages(sessionID, [response])
|
if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`)
|
||||||
const message = normalized.messages[0]
|
return {
|
||||||
if (!message) throw new Error(`Message not found: ${messageID}`)
|
message: cleanMessage(response.data.info),
|
||||||
return { message, parts: normalized.parts.get(messageID) ?? [] }
|
parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const replaceMessages = (sessionID: string, messages: Message[]) => {
|
const replaceMessages = (sessionID: string, messages: Message[]) => {
|
||||||
@@ -1000,8 +1040,8 @@ export function createServerSession(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "message.updated": {
|
case "message.updated": {
|
||||||
const info = (event.properties as { info: Message }).info
|
const info = cleanMessage((event.properties as { info: Message }).info)
|
||||||
indexProjectedMessage(info)
|
indexLegacyMessage(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)
|
||||||
@@ -1352,8 +1392,19 @@ 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
|
||||||
// TODO: Restore todos when the V2 client exposes a session todo API.
|
if ((await options?.protocol) === "v2") {
|
||||||
setData("todo", sessionID, [])
|
// TODO: Restore todos when the V2 API exposes a session todo snapshot.
|
||||||
|
setData("todo", sessionID, [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return runInflight(inflightTodo, sessionID, () => {
|
||||||
|
const active = generation(sessionID)
|
||||||
|
if (!options?.legacy) return Promise.resolve()
|
||||||
|
return (options.retry ?? retry)(() => options.legacy!.session.todo(sessionID)).then((result) => {
|
||||||
|
if (generations.get(sessionID) !== active) return
|
||||||
|
setData("todo", sessionID, reconcile(result, { key: "id" }))
|
||||||
|
})
|
||||||
|
})
|
||||||
},
|
},
|
||||||
history: {
|
history: {
|
||||||
more: (sessionID: string) =>
|
more: (sessionID: string) =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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,
|
||||||
@@ -83,7 +84,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 ServerApi["session"], {} as ServerApi["message"])
|
const session = createServerSession({} as OpencodeClient)
|
||||||
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, {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import type {
|
|||||||
import { toggleMcp } from "./global-sync/mcp"
|
import { toggleMcp } from "./global-sync/mcp"
|
||||||
import { createServerSession, type ServerSession } from "./server-session"
|
import { createServerSession, type ServerSession } from "./server-session"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
|
import type { LegacyCapabilities } from "@/utils/server-compat"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -131,11 +132,11 @@ export const loadMcpResourcesQuery = (
|
|||||||
placeholderData: {},
|
placeholderData: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadLspQuery = (scope: ServerScope, directory: string) =>
|
export const loadLspQuery = (scope: ServerScope, directory: string, legacy: LegacyCapabilities, enabled = true) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "lsp"] as const,
|
queryKey: [scope, directory, "lsp"] as const,
|
||||||
// TODO: Restore LSP status when the V2 client exposes an LSP API.
|
queryFn: () => legacy.lsp.status(directory),
|
||||||
queryFn: async () => [],
|
enabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadActiveSessionsQuery = (
|
export const loadActiveSessionsQuery = (
|
||||||
@@ -167,18 +168,19 @@ export function seedActiveSessionStatuses(
|
|||||||
function makeQueryOptionsApi(
|
function makeQueryOptionsApi(
|
||||||
scope: ServerScope,
|
scope: ServerScope,
|
||||||
serverAPI: ServerApi,
|
serverAPI: ServerApi,
|
||||||
|
protocolKind: Accessor<"v1" | "v2" | undefined>,
|
||||||
|
legacy: LegacyCapabilities,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
globalConfig: () => loadGlobalConfigQuery(scope),
|
globalConfig: () => loadGlobalConfigQuery(scope, legacy, protocolKind() === "v1"),
|
||||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||||
providers: (directory: PathKey | null) =>
|
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
||||||
loadProvidersQuery(scope, directory, serverAPI),
|
|
||||||
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
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),
|
lsp: (directory: PathKey) => loadLspQuery(scope, directory, legacy, protocolKind() === "v1"),
|
||||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,8 +196,21 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
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 session = createServerSession(serverSDK.api.session, serverSDK.api.message)
|
const session = createServerSession(
|
||||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
|
{ session: serverSDK.legacy.session },
|
||||||
|
serverSDK.currentApi.session,
|
||||||
|
serverSDK.currentApi.message,
|
||||||
|
{
|
||||||
|
protocol: serverSDK.protocol,
|
||||||
|
legacy: serverSDK.legacy,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const queryOptionsApi = makeQueryOptionsApi(
|
||||||
|
serverSDK.scope,
|
||||||
|
serverSDK.currentApi,
|
||||||
|
serverSDK.protocolKind,
|
||||||
|
serverSDK.legacy,
|
||||||
|
)
|
||||||
|
|
||||||
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)],
|
||||||
@@ -203,7 +218,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.api.session.active()
|
const active = await serverSDK.currentApi.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)
|
||||||
@@ -271,7 +286,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
queryKey: [serverSDK.scope, "bootstrap"],
|
queryKey: [serverSDK.scope, "bootstrap"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
await bootstrapGlobal({
|
await bootstrapGlobal({
|
||||||
serverAPI: serverSDK.api,
|
legacy: serverSDK.legacy,
|
||||||
|
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,
|
||||||
@@ -311,7 +328,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
void bootstrapInstance(directory)
|
void bootstrapInstance(directory)
|
||||||
},
|
},
|
||||||
onMcp: (directory, setStore) => {
|
onMcp: (directory, setStore) => {
|
||||||
void loadCommands(directory, serverSDK.api.command)
|
void loadCommands(directory, serverSDK.currentApi.command)
|
||||||
.then((commands) => setStore("command", commands))
|
.then((commands) => setStore("command", commands))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -363,7 +380,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
.fetchQuery({
|
.fetchQuery({
|
||||||
...queryOptionsApi.sessions(key),
|
...queryOptionsApi.sessions(key),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
loadRootSessions({ api: serverSDK.api.session, directory, limit })
|
loadRootSessions({ api: serverSDK.currentApi.session, directory, limit })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
const nonArchived = (x.data ?? [])
|
const nonArchived = (x.data ?? [])
|
||||||
.filter((s) => !!s?.id)
|
.filter((s) => !!s?.id)
|
||||||
@@ -431,7 +448,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
project: globalStore.project,
|
project: globalStore.project,
|
||||||
provider: globalStore.provider,
|
provider: globalStore.provider,
|
||||||
},
|
},
|
||||||
api: serverSDK.api,
|
legacy: serverSDK.legacy,
|
||||||
|
api: serverSDK.currentApi,
|
||||||
store: child[0],
|
store: child[0],
|
||||||
setStore: child[1],
|
setStore: child[1],
|
||||||
vcsCache: cache,
|
vcsCache: cache,
|
||||||
@@ -439,6 +457,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
translate: language.t,
|
translate: language.t,
|
||||||
queryClient,
|
queryClient,
|
||||||
session,
|
session,
|
||||||
|
protocol: serverSDK.protocol,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -477,7 +496,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") {
|
||||||
if ("info" in event.properties) homeSessions.apply(event as Parameters<typeof homeSessions.apply>[0])
|
homeSessions.apply(event)
|
||||||
}
|
}
|
||||||
homeSessions.refresh(event.type)
|
homeSessions.refresh(event.type)
|
||||||
if (eventType === "integration.connection.updated") void refreshProviders()
|
if (eventType === "integration.connection.updated") void refreshProviders()
|
||||||
@@ -549,6 +568,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
permission: session.data.permission,
|
permission: session.data.permission,
|
||||||
vcsCache: children.vcsCache.get(key),
|
vcsCache: children.vcsCache.get(key),
|
||||||
loadLsp: () => {
|
loadLsp: () => {
|
||||||
|
if (serverSDK.protocolKind() !== "v1") return
|
||||||
if (!children.active(key)) return
|
if (!children.active(key)) return
|
||||||
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
||||||
},
|
},
|
||||||
@@ -597,11 +617,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateConfigMutation = useMutation(() => ({
|
const updateConfigMutation = useMutation(() => ({
|
||||||
mutationFn: async (config: Config) => {
|
mutationFn: (config: Config) => serverSDK.legacy.config.update(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
|
||||||
@@ -640,24 +656,24 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
await toggleMcp({
|
await toggleMcp({
|
||||||
status,
|
status,
|
||||||
connect: async () => {
|
connect: async () => {
|
||||||
await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
|
await serverSDK.currentApi.mcp.connect({ server: name, location: { directory: key } })
|
||||||
},
|
},
|
||||||
disconnect: async () => {
|
disconnect: async () => {
|
||||||
await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
|
await serverSDK.currentApi.mcp.disconnect({ server: name, location: { directory: key } })
|
||||||
},
|
},
|
||||||
authenticate: async () => {
|
authenticate: async () => {
|
||||||
const server = (await serverSDK.api.mcp.list({ location: { directory: key } })).data.find(
|
const server = (await serverSDK.currentApi.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.api.integration.get({
|
const integration = await serverSDK.currentApi.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.api.integration.oauth.connect({
|
const attempt = await serverSDK.currentApi.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.api.pty.update({
|
await sdk.currentApi.pty.update({
|
||||||
ptyID: pty.id,
|
ptyID: pty.id,
|
||||||
location,
|
location,
|
||||||
title: pty.title,
|
title: pty.title,
|
||||||
@@ -268,10 +268,13 @@ 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.api.pty.create({ location, title: pty.title }).then((result) => result.data).catch((error: unknown) => {
|
const data = await sdk.currentApi.pty
|
||||||
console.error("Failed to clone terminal", error)
|
.create({ location, title: pty.title })
|
||||||
return undefined
|
.then((result) => result.data)
|
||||||
})
|
.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
|
||||||
@@ -308,7 +311,9 @@ 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.api.pty.create({ location, title: defaultTitle(nextNumber) }).then((result) => result.data)
|
return sdk.currentApi.pty
|
||||||
|
.create({ location, title: defaultTitle(nextNumber) })
|
||||||
|
.then((result) => result.data)
|
||||||
}
|
}
|
||||||
doCreate()
|
doCreate()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -412,7 +417,7 @@ function createWorkspaceTerminalSession(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await sdk.api.pty.remove({ ptyID: id, location }).catch((error: unknown) => {
|
await sdk.currentApi.pty.remove({ ptyID: id, location }).catch((error: unknown) => {
|
||||||
console.error("Failed to close terminal", error)
|
console.error("Failed to close terminal", error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export function createHomeProjectsController(home: HomeController) {
|
|||||||
home.server.context(conn).projects.move(worktree, index)
|
home.server.context(conn).projects.move(worktree, index)
|
||||||
},
|
},
|
||||||
canReveal: canRevealProject,
|
canReveal: canRevealProject,
|
||||||
|
canEdit: (conn: ServerConnection.Any) => home.server.context(conn).sdk.protocolKind() === "v1",
|
||||||
reveal: (conn: ServerConnection.Any, project: LocalProject) => {
|
reveal: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||||
if (!platform.openPath || !canRevealProject(conn)) return
|
if (!platform.openPath || !canRevealProject(conn)) return
|
||||||
platform.openPath(project.worktree).catch((cause: unknown) =>
|
platform.openPath(project.worktree).catch((cause: unknown) =>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type HomeProjectsViewProps = {
|
|||||||
canDefaultServer: Accessor<boolean>
|
canDefaultServer: Accessor<boolean>
|
||||||
defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
|
defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
|
||||||
canRevealProject: (server: ServerConnection.Any) => boolean
|
canRevealProject: (server: ServerConnection.Any) => boolean
|
||||||
|
canEditProject: (server: ServerConnection.Any) => boolean
|
||||||
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
|
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
|
||||||
onWheel: (event: WheelEvent) => void
|
onWheel: (event: WheelEvent) => void
|
||||||
onChooseProject: (server: ServerConnection.Any) => void
|
onChooseProject: (server: ServerConnection.Any) => void
|
||||||
@@ -548,9 +549,11 @@ function HomeProjectRow(
|
|||||||
<MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
|
<MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
|
||||||
{props.language.t("command.session.new")}
|
{props.language.t("command.session.new")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
|
<Show when={props.canEditProject(props.server)}>
|
||||||
{props.language.t("dialog.project.edit.title")}
|
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
|
||||||
</MenuV2.Item>
|
{props.language.t("dialog.project.edit.title")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
</Show>
|
||||||
<Show when={props.canRevealProject(props.server)}>
|
<Show when={props.canRevealProject(props.server)}>
|
||||||
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
|
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
|
||||||
{props.language.t(
|
{props.language.t(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
|
|||||||
canDefaultServer={props.projects.server.canDefault}
|
canDefaultServer={props.projects.server.canDefault}
|
||||||
defaultServerKey={props.projects.server.defaultKey}
|
defaultServerKey={props.projects.server.defaultKey}
|
||||||
canRevealProject={props.projects.project.canReveal}
|
canRevealProject={props.projects.project.canReveal}
|
||||||
|
canEditProject={props.projects.project.canEdit}
|
||||||
unseenCount={props.projects.project.unseenCount}
|
unseenCount={props.projects.project.unseenCount}
|
||||||
onWheel={props.scroll.viewport.containWheel}
|
onWheel={props.scroll.viewport.containWheel}
|
||||||
onChooseProject={props.projects.project.choose}
|
onChooseProject={props.projects.project.choose}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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"
|
||||||
@@ -23,7 +24,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
|||||||
if (!value) return []
|
if (!value) return []
|
||||||
return sessions.data
|
return sessions.data
|
||||||
.searchRecords()
|
.searchRecords()
|
||||||
.filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
|
.filter((record) => `${displayLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
|
||||||
})
|
})
|
||||||
const active = createMemo(() => {
|
const active = createMemo(() => {
|
||||||
const records = results()
|
const records = results()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Session } from "@/types"
|
import type { Session, V2SessionListResponse } from "@/types"
|
||||||
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useMarked } from "@opencode-ai/ui/context/marked"
|
import { useMarked } from "@opencode-ai/ui/context/marked"
|
||||||
@@ -69,7 +69,10 @@ 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.api.session.list(input, options),
|
(input, options) =>
|
||||||
|
ctx.sdk.currentApi.session.list(input, options).then((data) => ({
|
||||||
|
data: data as unknown as V2SessionListResponse,
|
||||||
|
})),
|
||||||
eventSequence,
|
eventSequence,
|
||||||
signal,
|
signal,
|
||||||
)
|
)
|
||||||
@@ -179,6 +182,7 @@ export function createHomeSessionsController(home: HomeController) {
|
|||||||
showProjectName: () => !home.project.selected(),
|
showProjectName: () => !home.project.selected(),
|
||||||
server: () => home.selection.value().server,
|
server: () => home.selection.value().server,
|
||||||
canCreate: () => !!home.project.newSession(),
|
canCreate: () => !!home.project.newSession(),
|
||||||
|
canArchive: () => home.server.focusedContext()?.sdk.protocolKind() === "v1",
|
||||||
create: home.project.openNewSession,
|
create: home.project.openNewSession,
|
||||||
open: (session: Session, options?: OpenSessionOptions) => {
|
open: (session: Session, options?: OpenSessionOptions) => {
|
||||||
const directoryKey = pathKey(session.directory)
|
const directoryKey = pathKey(session.directory)
|
||||||
@@ -214,8 +218,7 @@ export function createHomeSessionsController(home: HomeController) {
|
|||||||
await archiveHomeSession({
|
await archiveHomeSession({
|
||||||
server: ServerConnection.key(conn),
|
server: ServerConnection.key(conn),
|
||||||
session,
|
session,
|
||||||
// TODO: Restore archiving when the V2 client exposes a session archive API.
|
archive: (sessionID) => ctx.sdk.legacy.session.archive(sessionID, session.directory),
|
||||||
archive: async (_sessionID) => Promise.reject(new Error("Session archiving is unavailable")),
|
|
||||||
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,
|
||||||
@@ -43,6 +43,7 @@ export type HomeSessionsViewProps = {
|
|||||||
showProjectName: Accessor<boolean>
|
showProjectName: Accessor<boolean>
|
||||||
server: Accessor<ServerConnection.Key>
|
server: Accessor<ServerConnection.Key>
|
||||||
canCreateSession: Accessor<boolean>
|
canCreateSession: Accessor<boolean>
|
||||||
|
canArchiveSession: Accessor<boolean>
|
||||||
searchValue: Accessor<string>
|
searchValue: Accessor<string>
|
||||||
searchPlaceholder: Accessor<string>
|
searchPlaceholder: Accessor<string>
|
||||||
searchOpen: Accessor<boolean>
|
searchOpen: Accessor<boolean>
|
||||||
@@ -344,7 +345,7 @@ function HomeSessionSearchResultRow(
|
|||||||
selected: boolean
|
selected: boolean
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
const title = createMemo(() => displayLabel(props.record.session))
|
||||||
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 +416,7 @@ function HomeSessionGroupHeader(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
||||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
const title = createMemo(() => displayLabel(props.record.session))
|
||||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -460,7 +461,8 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
|
|||||||
group-hover/session:opacity-100 focus-within:opacity-100
|
group-hover/session:opacity-100 focus-within:opacity-100
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
|
<Show when={props.canArchiveSession()}>
|
||||||
|
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
data-action="home-session-archive"
|
data-action="home-session-archive"
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
@@ -473,7 +475,8 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
|
|||||||
void props.onArchiveSession(props.record.session)
|
void props.onArchiveSession(props.record.session)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function HomeSessions(props: {
|
|||||||
showProjectName={props.sessions.session.showProjectName}
|
showProjectName={props.sessions.session.showProjectName}
|
||||||
server={props.sessions.session.server}
|
server={props.sessions.session.server}
|
||||||
canCreateSession={props.sessions.session.canCreate}
|
canCreateSession={props.sessions.session.canCreate}
|
||||||
|
canArchiveSession={props.sessions.session.canArchive}
|
||||||
searchValue={props.search.query.value}
|
searchValue={props.search.query.value}
|
||||||
searchPlaceholder={props.search.query.placeholder}
|
searchPlaceholder={props.search.query.placeholder}
|
||||||
searchOpen={props.search.query.open}
|
searchOpen={props.search.query.open}
|
||||||
|
|||||||
@@ -390,6 +390,22 @@ 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" ||
|
||||||
@@ -856,14 +872,12 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function archiveSession(session: Session) {
|
async function archiveSession(session: Session) {
|
||||||
// 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().legacy.session.archive(session.id, session.directory)
|
||||||
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)
|
||||||
@@ -961,8 +975,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",
|
||||||
// TODO: Restore the command when the V2 client exposes session archive.
|
hidden: serverSDK().protocolKind() !== "v1",
|
||||||
disabled: true,
|
disabled: !params.dir || !params.id,
|
||||||
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)
|
||||||
@@ -1171,10 +1185,13 @@ 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().api.project.current({ location: { directory: root } }),
|
project?.id ?? serverSDK().currentApi.project.current({ location: { directory: root } }),
|
||||||
)
|
)
|
||||||
.then((value) => (typeof value === "string" ? value : value.id))
|
.then((value) => (typeof value === "string" ? value : value.id))
|
||||||
.then((projectID) => serverSDK().api.project.directories({ projectID, location: { directory: root } }))
|
.then(async (projectID) => {
|
||||||
|
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])
|
||||||
@@ -1219,7 +1236,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().api.session, {
|
session: await listAllSessions(serverSDK().currentApi.session, {
|
||||||
directory: item,
|
directory: item,
|
||||||
parentID: null,
|
parentID: null,
|
||||||
order: "desc",
|
order: "desc",
|
||||||
@@ -1283,7 +1300,13 @@ 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") {
|
||||||
// TODO: Restore project renames when the V2 client exposes a project update API.
|
const result = await serverSDK().legacy.project
|
||||||
|
.update({ projectID: project.id, directory: project.worktree, name })
|
||||||
|
.then((response) => response.data)
|
||||||
|
if (!result) return
|
||||||
|
serverSync().set("project", (items) =>
|
||||||
|
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1379,7 +1402,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()
|
||||||
.api.projectCopy.remove({ projectID, directory, force: false, location: { directory: root } })
|
.currentApi.projectCopy.remove({ projectID, directory, force: false, location: { directory: root } })
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -1437,7 +1460,9 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
const dismiss = () => toaster.dismiss(progress)
|
const dismiss = () => toaster.dismiss(progress)
|
||||||
|
|
||||||
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
|
const sessions = await listAllSessions(serverSDK().currentApi.session, { directory, order: "desc" }).catch(
|
||||||
|
() => [],
|
||||||
|
)
|
||||||
|
|
||||||
clearWorkspaceTerminals(
|
clearWorkspaceTerminals(
|
||||||
directory,
|
directory,
|
||||||
@@ -1445,8 +1470,16 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
platform,
|
platform,
|
||||||
serverSDK().scope,
|
serverSDK().scope,
|
||||||
)
|
)
|
||||||
// TODO: Restore workspace reset and instance disposal when V2 exposes these operations.
|
const result = await serverSDK()
|
||||||
const result = false
|
.legacy.workspace.reset(root, 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)
|
||||||
@@ -1454,6 +1487,17 @@ 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()
|
||||||
|
.legacy.session.archive(session.id, session.directory)
|
||||||
|
.catch(() => undefined),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
setBusy(directory, false)
|
setBusy(directory, false)
|
||||||
dismiss()
|
dismiss()
|
||||||
|
|
||||||
@@ -1544,7 +1588,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
const sessions = await listAllSessions(serverSDK().api.session, {
|
const sessions = await listAllSessions(serverSDK().currentApi.session, {
|
||||||
directory: props.directory,
|
directory: props.directory,
|
||||||
order: "desc",
|
order: "desc",
|
||||||
}).catch(() => [])
|
}).catch(() => [])
|
||||||
@@ -1786,7 +1830,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
clearSidebarHoverState()
|
clearSidebarHoverState()
|
||||||
const created = project.id
|
const created = project.id
|
||||||
? await serverSDK()
|
? await serverSDK()
|
||||||
.api.projectCopy.create({
|
.currentApi.projectCopy.create({
|
||||||
projectID: project.id,
|
projectID: project.id,
|
||||||
strategy: "git_worktree",
|
strategy: "git_worktree",
|
||||||
directory: getDirectory(project.worktree),
|
directory: getDirectory(project.worktree),
|
||||||
@@ -1836,6 +1880,8 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
clearHoverProjectSoon,
|
clearHoverProjectSoon,
|
||||||
prefetchSession,
|
prefetchSession,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
|
canArchive: () => serverSDK().protocolKind() === "v1",
|
||||||
|
canResetWorkspace: () => serverSDK().protocolKind() === "v1",
|
||||||
workspaceName,
|
workspaceName,
|
||||||
renameWorkspace,
|
renameWorkspace,
|
||||||
editorOpen,
|
editorOpen,
|
||||||
@@ -1872,6 +1918,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
openSidebar: () => layout.sidebar.open(),
|
openSidebar: () => layout.sidebar.open(),
|
||||||
closeProject,
|
closeProject,
|
||||||
showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj),
|
showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj),
|
||||||
|
canEditProject: () => serverSDK().protocolKind() === "v1",
|
||||||
toggleProjectWorkspaces,
|
toggleProjectWorkspaces,
|
||||||
workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
|
workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
|
||||||
workspaceIds,
|
workspaceIds,
|
||||||
@@ -1882,6 +1929,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
clearHoverProjectSoon,
|
clearHoverProjectSoon,
|
||||||
prefetchSession,
|
prefetchSession,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
|
canArchive: () => serverSDK().protocolKind() === "v1",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1973,15 +2021,13 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
<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">
|
||||||
<Show
|
<Show
|
||||||
when={!project.id || project.id === "global"}
|
when={serverSDK().protocolKind() === "v1" || !project.id || project.id === "global"}
|
||||||
fallback={<span class="text-14-medium text-text-strong truncate">{projectName()}</span>}
|
fallback={<span class="text-14-medium text-text-strong truncate">{projectName()}</span>}
|
||||||
>
|
>
|
||||||
<InlineEditor
|
<InlineEditor
|
||||||
id={`project:${projectId()}`}
|
id={`project:${projectId()}`}
|
||||||
value={projectName}
|
value={projectName}
|
||||||
onSave={(next) => {
|
onSave={(next) => void renameProject(project, next)}
|
||||||
void renameProject(project, next)
|
|
||||||
}}
|
|
||||||
class="text-14-medium text-text-strong truncate"
|
class="text-14-medium text-text-strong truncate"
|
||||||
displayClass="text-14-medium text-text-strong truncate"
|
displayClass="text-14-medium text-text-strong truncate"
|
||||||
stopPropagation
|
stopPropagation
|
||||||
@@ -2021,13 +2067,11 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
/>
|
/>
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content class="mt-1">
|
<DropdownMenu.Content class="mt-1">
|
||||||
<DropdownMenu.Item
|
<Show when={serverSDK().protocolKind() === "v1"}>
|
||||||
onSelect={() => {
|
<DropdownMenu.Item onSelect={() => showEditProjectDialog(server.current!, project)}>
|
||||||
showEditProjectDialog(server.current!, project)
|
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
||||||
}}
|
</DropdownMenu.Item>
|
||||||
>
|
</Show>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
data-action="project-workspaces-toggle"
|
data-action="project-workspaces-toggle"
|
||||||
data-project={slug()}
|
data-project={slug()}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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"
|
||||||
@@ -14,7 +15,6 @@ 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"
|
||||||
|
|
||||||
@@ -87,6 +87,7 @@ export type SessionItemProps = {
|
|||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||||
archiveSession: (session: Session) => Promise<void>
|
archiveSession: (session: Session) => Promise<void>
|
||||||
|
canArchive: Accessor<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
const SessionRow = (props: {
|
const SessionRow = (props: {
|
||||||
@@ -104,7 +105,7 @@ const SessionRow = (props: {
|
|||||||
warmPress: () => void
|
warmPress: () => void
|
||||||
warmFocus: () => void
|
warmFocus: () => void
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const title = () => sessionTitle(props.session.title)
|
const title = () => displayLabel(props.session)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<A
|
<A
|
||||||
@@ -229,7 +230,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
fallback={
|
fallback={
|
||||||
<Tooltip
|
<Tooltip
|
||||||
placement={props.mobile ? "bottom" : "right"}
|
placement={props.mobile ? "bottom" : "right"}
|
||||||
value={sessionTitle(props.session.title)}
|
value={displayLabel(props.session)}
|
||||||
gutter={10}
|
gutter={10}
|
||||||
class="min-w-0 w-full"
|
class="min-w-0 w-full"
|
||||||
>
|
>
|
||||||
@@ -241,8 +242,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TODO: Restore the archive action when the V2 client exposes session archive. */}
|
<Show when={!props.level && props.canArchive()}>
|
||||||
<Show when={false}>
|
|
||||||
<div
|
<div
|
||||||
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
||||||
classList={{
|
classList={{
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type ProjectSidebarContext = {
|
|||||||
openSidebar: () => void
|
openSidebar: () => void
|
||||||
closeProject: (directory: string) => void
|
closeProject: (directory: string) => void
|
||||||
showEditProjectDialog: (project: LocalProject) => void
|
showEditProjectDialog: (project: LocalProject) => void
|
||||||
|
canEditProject: Accessor<boolean>
|
||||||
toggleProjectWorkspaces: (project: LocalProject) => void
|
toggleProjectWorkspaces: (project: LocalProject) => void
|
||||||
workspacesEnabled: (project: LocalProject) => boolean
|
workspacesEnabled: (project: LocalProject) => boolean
|
||||||
workspaceIds: (project: LocalProject) => string[]
|
workspaceIds: (project: LocalProject) => string[]
|
||||||
@@ -65,6 +66,7 @@ const ProjectTile = (props: {
|
|||||||
onProjectFocus: (worktree: string) => void
|
onProjectFocus: (worktree: string) => void
|
||||||
navigateToProject: (directory: string) => void
|
navigateToProject: (directory: string) => void
|
||||||
showEditProjectDialog: (project: LocalProject) => void
|
showEditProjectDialog: (project: LocalProject) => void
|
||||||
|
canEditProject: Accessor<boolean>
|
||||||
toggleProjectWorkspaces: (project: LocalProject) => void
|
toggleProjectWorkspaces: (project: LocalProject) => void
|
||||||
workspacesEnabled: (project: LocalProject) => boolean
|
workspacesEnabled: (project: LocalProject) => boolean
|
||||||
closeProject: (directory: string) => void
|
closeProject: (directory: string) => void
|
||||||
@@ -148,9 +150,11 @@ const ProjectTile = (props: {
|
|||||||
</ContextMenu.Trigger>
|
</ContextMenu.Trigger>
|
||||||
<ContextMenu.Portal>
|
<ContextMenu.Portal>
|
||||||
<ContextMenu.Content>
|
<ContextMenu.Content>
|
||||||
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
<Show when={props.canEditProject()}>
|
||||||
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
||||||
</ContextMenu.Item>
|
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
</Show>
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
data-action="project-workspaces-toggle"
|
data-action="project-workspaces-toggle"
|
||||||
data-project={base64Encode(props.project.worktree)}
|
data-project={base64Encode(props.project.worktree)}
|
||||||
@@ -331,6 +335,7 @@ export const SortableProject = (props: {
|
|||||||
onProjectFocus={props.ctx.onProjectFocus}
|
onProjectFocus={props.ctx.onProjectFocus}
|
||||||
navigateToProject={props.ctx.navigateToProject}
|
navigateToProject={props.ctx.navigateToProject}
|
||||||
showEditProjectDialog={props.ctx.showEditProjectDialog}
|
showEditProjectDialog={props.ctx.showEditProjectDialog}
|
||||||
|
canEditProject={props.ctx.canEditProject}
|
||||||
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
|
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
|
||||||
workspacesEnabled={props.ctx.workspacesEnabled}
|
workspacesEnabled={props.ctx.workspacesEnabled}
|
||||||
closeProject={props.ctx.closeProject}
|
closeProject={props.ctx.closeProject}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export type WorkspaceSidebarContext = {
|
|||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||||
archiveSession: (session: Session) => Promise<void>
|
archiveSession: (session: Session) => Promise<void>
|
||||||
|
canArchive: Accessor<boolean>
|
||||||
|
canResetWorkspace: Accessor<boolean>
|
||||||
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
|
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
|
||||||
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
|
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
|
||||||
editorOpen: (id: string) => boolean
|
editorOpen: (id: string) => boolean
|
||||||
@@ -151,6 +153,7 @@ const WorkspaceActions = (props: {
|
|||||||
workspaceValue: Accessor<string>
|
workspaceValue: Accessor<string>
|
||||||
openEditor: WorkspaceSidebarContext["openEditor"]
|
openEditor: WorkspaceSidebarContext["openEditor"]
|
||||||
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
|
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
|
||||||
|
canResetWorkspace: WorkspaceSidebarContext["canResetWorkspace"]
|
||||||
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
|
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
|
||||||
root: string
|
root: string
|
||||||
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
|
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
|
||||||
@@ -199,13 +202,14 @@ 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
|
<Show when={props.canResetWorkspace()}>
|
||||||
// TODO: Restore reset when V2 exposes project-copy reset and instance disposal.
|
<DropdownMenu.Item
|
||||||
// onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
|
disabled={props.local() || props.busy()}
|
||||||
disabled
|
onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
disabled={props.local() || props.busy()}
|
disabled={props.local() || props.busy()}
|
||||||
onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
|
onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
|
||||||
@@ -273,6 +277,7 @@ const WorkspaceSessionList = (props: {
|
|||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
prefetchSession={props.ctx.prefetchSession}
|
prefetchSession={props.ctx.prefetchSession}
|
||||||
archiveSession={props.ctx.archiveSession}
|
archiveSession={props.ctx.archiveSession}
|
||||||
|
canArchive={props.ctx.canArchive}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -417,6 +422,7 @@ export const SortableWorkspace = (props: {
|
|||||||
workspaceValue={workspaceValue}
|
workspaceValue={workspaceValue}
|
||||||
openEditor={props.ctx.openEditor}
|
openEditor={props.ctx.openEditor}
|
||||||
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
|
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
|
||||||
|
canResetWorkspace={props.ctx.canResetWorkspace}
|
||||||
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
|
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
|
||||||
root={props.project.worktree}
|
root={props.project.worktree}
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import { useNotification } from "@/context/notification"
|
|||||||
import { PromptProvider, usePrompt } from "@/context/prompt"
|
import { PromptProvider, usePrompt } from "@/context/prompt"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
@@ -361,6 +361,7 @@ export default function Page() {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
@@ -847,8 +848,11 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gitMutation = useMutation(() => ({
|
const gitMutation = useMutation(() => ({
|
||||||
// TODO: Restore Git initialization when the V2 client exposes this operation.
|
mutationFn: () => sdk().legacy.project.initGit(sdk().directory),
|
||||||
mutationFn: async () => Promise.reject(new Error("Git initialization is unavailable")),
|
onSuccess: (x) => {
|
||||||
|
if (!x.data) return
|
||||||
|
upsert(x.data)
|
||||||
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
@@ -893,17 +897,19 @@ export default function Page() {
|
|||||||
() => {
|
() => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
return [
|
return [
|
||||||
|
protocol(),
|
||||||
sdk().directory,
|
sdk().directory,
|
||||||
id,
|
id,
|
||||||
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
|
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
|
||||||
id ? composer.blocked() : false,
|
id ? composer.blocked() : false,
|
||||||
] as const
|
] as const
|
||||||
},
|
},
|
||||||
([dir, id, status, blocked]) => {
|
([serverProtocol, dir, id, status, blocked]) => {
|
||||||
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||||
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||||
todoFrame = undefined
|
todoFrame = undefined
|
||||||
todoTimer = undefined
|
todoTimer = undefined
|
||||||
|
if (serverProtocol !== "v1") return
|
||||||
if (!id) return
|
if (!id) return
|
||||||
if (status === "idle" && !blocked) return
|
if (status === "idle" && !blocked) return
|
||||||
const cached = untrack(() => sync().data.todo[id] !== undefined)
|
const cached = untrack(() => sync().data.todo[id] !== undefined)
|
||||||
@@ -1214,7 +1220,13 @@ export default function Page() {
|
|||||||
{language.t("session.review.noVcs.createGit.description")}
|
{language.t("session.review.noVcs.createGit.description")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* TODO: Restore the init button when the V2 client exposes Git initialization. */}
|
<Show when={protocol() === "v1"}>
|
||||||
|
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
|
||||||
|
{gitMutation.isPending
|
||||||
|
? language.t("session.review.noVcs.createGit.actionLoading")
|
||||||
|
: language.t("session.review.noVcs.createGit.action")}
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1247,7 +1259,7 @@ 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()) {
|
||||||
// TODO: Restore SessionReviewEmptyNoGitV2 when the V2 client exposes Git initialization.
|
if (protocol() === "v1") return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
|
||||||
return empty(language.t("session.review.noVcs.createGit.description"))
|
return empty(language.t("session.review.noVcs.createGit.description"))
|
||||||
}
|
}
|
||||||
return <SessionReviewEmptyChangesV2 />
|
return <SessionReviewEmptyChangesV2 />
|
||||||
@@ -1718,10 +1730,9 @@ export default function Page() {
|
|||||||
setFollowup("failed", input.sessionID, undefined)
|
setFollowup("failed", input.sessionID, undefined)
|
||||||
|
|
||||||
const ok = await sendFollowupDraft({
|
const ok = await sendFollowupDraft({
|
||||||
api: sdk().api.session,
|
api: sdk().currentApi.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) => {
|
||||||
@@ -1815,13 +1826,13 @@ export default function Page() {
|
|||||||
const halt = (sessionID: string) =>
|
const halt = (sessionID: string) =>
|
||||||
busy(sessionID)
|
busy(sessionID)
|
||||||
? sdk()
|
? sdk()
|
||||||
.api.session.interrupt({ sessionID })
|
.currentApi.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().api.session
|
const session = sdk().currentApi.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)
|
||||||
@@ -1844,7 +1855,7 @@ export default function Page() {
|
|||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const session = sdk().api.session
|
const session = sdk().currentApi.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
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ type TabsInput = {
|
|||||||
fileBrowser?: Accessor<boolean>
|
fileBrowser?: Accessor<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
|
|
||||||
|
|
||||||
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
|
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
|
||||||
return input.opened && input.visible
|
return input.opened && input.visible
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
|||||||
|
|
||||||
const readFile = async (path: string) => {
|
const readFile = async (path: string) => {
|
||||||
return sdk()
|
return sdk()
|
||||||
.api.file.read({ path, location: { directory: sdk().directory } })
|
.currentApi.file.read({ path, location: { directory: sdk().directory } })
|
||||||
.then((data) => ({ type: "text" as const, content: new TextDecoder().decode(data) }))
|
.then((content) => ({ type: "text" as const, content: new TextDecoder().decode(content) }))
|
||||||
.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
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ import { SessionContextUsage } from "@/components/session-context-usage"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
@@ -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 { sessionTitle } from "@/utils/session-title"
|
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||||
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,12 +296,14 @@ export function MessageTimeline(props: {
|
|||||||
if (!id) return
|
if (!id) return
|
||||||
return sync().session.get(id)
|
return sync().session.get(id)
|
||||||
})
|
})
|
||||||
const titleValue = createMemo(() => info()?.title)
|
const titleLabel = createMemo(() => {
|
||||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
const session = info()
|
||||||
|
if (!session) return
|
||||||
|
return displayLabel(session)
|
||||||
|
})
|
||||||
const shareUrl = createMemo(() => info()?.share?.url)
|
const shareUrl = createMemo(() => info()?.share?.url)
|
||||||
// TODO: Restore these actions when the V2 client exposes session sharing.
|
const protocol = useServerProtocol()
|
||||||
// const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
const shareEnabled = createMemo(() => protocol() === "v1" && 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()
|
||||||
@@ -313,7 +315,10 @@ 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(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
|
const parentTitle = createMemo(() => {
|
||||||
|
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(() => {
|
||||||
@@ -331,7 +336,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(() => !!(titleValue() || parentID()))
|
const showHeader = createMemo(() => !!(titleLabel() || parentID()))
|
||||||
const projection = createTimelineProjection({
|
const projection = createTimelineProjection({
|
||||||
messages: sessionMessages,
|
messages: sessionMessages,
|
||||||
userMessages: () => props.userMessages,
|
userMessages: () => props.userMessages,
|
||||||
@@ -661,16 +666,14 @@ export function MessageTimeline(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shareMutation = useMutation(() => ({
|
const shareMutation = useMutation(() => ({
|
||||||
// TODO: Restore sharing when the V2 client exposes a session sharing API.
|
mutationFn: (id: string) => serverSDK().legacy.session.share(id),
|
||||||
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(() => ({
|
||||||
// TODO: Restore unsharing when the V2 client exposes a session sharing API.
|
mutationFn: (id: string) => serverSDK().legacy.session.unshare(id),
|
||||||
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)
|
||||||
},
|
},
|
||||||
@@ -678,7 +681,7 @@ export function MessageTimeline(props: {
|
|||||||
|
|
||||||
const titleMutation = useMutation(() => ({
|
const titleMutation = useMutation(() => ({
|
||||||
mutationFn: (input: { id: string; title: string }) =>
|
mutationFn: (input: { id: string; title: string }) =>
|
||||||
sdk().api.session.rename({ sessionID: input.id, title: input.title }),
|
sdk().currentApi.session.rename({ sessionID: input.id, title: input.title }),
|
||||||
onSuccess: (_, input) => {
|
onSuccess: (_, input) => {
|
||||||
sync().set(
|
sync().set(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
@@ -820,8 +823,8 @@ export function MessageTimeline(props: {
|
|||||||
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])
|
||||||
|
|
||||||
// TODO: Restore archiving when the V2 client exposes a session archive API.
|
await sdk()
|
||||||
await Promise.reject(new Error("Session archiving is unavailable"))
|
.legacy.session.archive(sessionID, sdk().directory)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
sync().set(
|
sync().set(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
@@ -850,7 +853,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()
|
||||||
.api.session.remove({ sessionID })
|
.currentApi.session.remove({ sessionID })
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -914,9 +917,10 @@ export function MessageTimeline(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DialogDeleteSession(props: { sessionID: string }) {
|
function DialogDeleteSession(props: { sessionID: string }) {
|
||||||
const name = createMemo(
|
const name = createMemo(() => {
|
||||||
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
const session = sync().session.get(props.sessionID)
|
||||||
)
|
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()
|
||||||
@@ -1569,7 +1573,11 @@ export function MessageTimeline(props: {
|
|||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
{/* TODO: Restore archive when the V2 client exposes session archive. */}
|
<Show when={protocol() === "v1"}>
|
||||||
|
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||||
|
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||||
@@ -1638,7 +1646,11 @@ export function MessageTimeline(props: {
|
|||||||
{language.t("session.share.action.share")}...
|
{language.t("session.share.action.share")}...
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
{/* TODO: Restore archive when the V2 client exposes session archive. */}
|
<Show when={protocol() === "v1"}>
|
||||||
|
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
||||||
|
{language.t("common.archive")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
</Show>
|
||||||
<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,17 +38,19 @@ 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))
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { UserMessage } from "@/types"
|
|||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionOwnership } from "./session-ownership"
|
import { createSessionOwnership } from "./session-ownership"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
|
import { useServerProtocol } from "@/context/server-sdk"
|
||||||
|
|
||||||
export type SessionCommandContext = {
|
export type SessionCommandContext = {
|
||||||
navigateMessageByOffset: (offset: number) => void
|
navigateMessageByOffset: (offset: number) => void
|
||||||
@@ -43,6 +44,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
@@ -193,8 +195,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Restore sharing when the V2 client exposes a session sharing API.
|
const url = await sdk()
|
||||||
const url = undefined
|
.legacy.session.share(sessionID)
|
||||||
|
.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"),
|
||||||
@@ -211,12 +215,22 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
// TODO: Restore unsharing when the V2 client exposes a session sharing API.
|
await sdk()
|
||||||
showToast({
|
.legacy.session.unshare(sessionID)
|
||||||
title: language.t("toast.session.unshare.failed.title"),
|
.then(() =>
|
||||||
description: language.t("toast.session.unshare.failed.description"),
|
showToast({
|
||||||
variant: "error",
|
title: language.t("toast.session.unshare.success.title"),
|
||||||
})
|
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 = () => {
|
||||||
@@ -294,7 +308,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().api.session
|
const session = sdk().currentApi.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
|
||||||
@@ -322,7 +336,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().api.session
|
const session = sdk().currentApi.session
|
||||||
const messages = userMessages()
|
const messages = userMessages()
|
||||||
const promptSession = prompt.capture()
|
const promptSession = prompt.capture()
|
||||||
|
|
||||||
@@ -354,7 +368,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
await sdk().api.session.compact({ sessionID })
|
await sdk().currentApi.session.compact({ sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
const fork = () => {
|
const fork = () => {
|
||||||
@@ -365,10 +379,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shareCmds = () => {
|
const shareCmds = () => {
|
||||||
// TODO: Restore these commands when the V2 client exposes session sharing.
|
if (protocol() !== "v1") return []
|
||||||
// if (sync().data.config.share === "disabled") return []
|
if (sync().data.config.share === "disabled") return []
|
||||||
return []
|
|
||||||
/*
|
|
||||||
return [
|
return [
|
||||||
sessionCommand({
|
sessionCommand({
|
||||||
id: "session.share",
|
id: "session.share",
|
||||||
@@ -389,7 +401,6 @@ 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()
|
||||||
.api.file.read({ path, location: { directory: sdk().directory } })
|
.currentApi.file.read({ path, location: { directory: sdk().directory } })
|
||||||
.then((data) => ({ type: "text" as const, content: new TextDecoder().decode(data) }))
|
.then((content) => ({ type: "text" as const, content: new TextDecoder().decode(content) }))
|
||||||
.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
|
||||||
|
|||||||
+39
-131
@@ -1,57 +1,59 @@
|
|||||||
import type {
|
import type {
|
||||||
EventSubscribeOutput,
|
Agent,
|
||||||
FileDiffInfo,
|
Config,
|
||||||
FileDiffLegacyInfo,
|
Event,
|
||||||
ProjectListOutput,
|
FileContent,
|
||||||
|
FileNode,
|
||||||
|
LspStatus,
|
||||||
|
Path,
|
||||||
|
PermissionRequest,
|
||||||
|
Project,
|
||||||
|
Provider,
|
||||||
|
ProviderAuthResponse,
|
||||||
|
ProviderListResponse,
|
||||||
QuestionAnswer,
|
QuestionAnswer,
|
||||||
QuestionInfo,
|
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
SessionInfo,
|
Session,
|
||||||
SessionNotFoundError,
|
SessionNotFoundError,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SessionV1Info,
|
SessionV2Info,
|
||||||
SessionsResponse,
|
SnapshotFileDiff,
|
||||||
} from "@opencode-ai/client/promise"
|
Todo,
|
||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
V2SessionListResponse,
|
||||||
|
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 } }
|
||||||
@@ -239,100 +241,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
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 }
|
||||||
|
question?: { id: string; sessionID: string; questions: never[]; tool?: { messageID: string; callID: 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" && new URL(request.url).pathname === "/question")
|
||||||
|
return Response.json(responses?.question ?? [])
|
||||||
|
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("translates V1 question tool call IDs", async () => {
|
||||||
|
const { api } = setup("v1", {
|
||||||
|
question: [
|
||||||
|
{
|
||||||
|
id: "que_1",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
questions: [],
|
||||||
|
tool: { messageID: "msg_1", callID: "call_1" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect((await api.question.request.list()).data[0]?.tool).toEqual({ messageID: "msg_1", id: "call_1" })
|
||||||
|
})
|
||||||
|
|
||||||
|
/*
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
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 {
|
||||||
|
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"]>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLegacyCapabilities(input: CompatibleInput) {
|
||||||
|
const directory = (value?: string) => value ?? input.directory
|
||||||
|
const client = (value?: string) => input.legacy(directory(value))
|
||||||
|
const requireV1 = async () => {
|
||||||
|
if ((await input.protocol) !== "v1") throw new Error("This capability is unavailable on V2 servers")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
global: async () => {
|
||||||
|
await requireV1()
|
||||||
|
return (await client().global.config.get()).data ?? {}
|
||||||
|
},
|
||||||
|
directory: async (value?: string) => {
|
||||||
|
await requireV1()
|
||||||
|
return (await client(value).config.get()).data ?? {}
|
||||||
|
},
|
||||||
|
update: async (config: NonNullable<Parameters<LegacyClient["global"]["config"]["update"]>[0]>["config"]) => {
|
||||||
|
await requireV1()
|
||||||
|
return client().global.config.update({ config })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
set: async (value: Parameters<LegacyClient["auth"]["set"]>[0]) => {
|
||||||
|
await requireV1()
|
||||||
|
return client().auth.set(value)
|
||||||
|
},
|
||||||
|
remove: async (value: Parameters<LegacyClient["auth"]["remove"]>[0]) => {
|
||||||
|
await requireV1()
|
||||||
|
return client().auth.remove(value)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
get: (value: Parameters<LegacyClient["session"]["get"]>[0]) => client().session.get(value),
|
||||||
|
messages: (value: Parameters<LegacyClient["session"]["messages"]>[0]) => client().session.messages(value),
|
||||||
|
message: (value: Parameters<LegacyClient["session"]["message"]>[0]) => client().session.message(value),
|
||||||
|
share: async (sessionID: string) => {
|
||||||
|
await requireV1()
|
||||||
|
return client().session.share({ sessionID })
|
||||||
|
},
|
||||||
|
unshare: async (sessionID: string) => {
|
||||||
|
await requireV1()
|
||||||
|
return client().session.unshare({ sessionID })
|
||||||
|
},
|
||||||
|
archive: async (sessionID: string, value?: string) => {
|
||||||
|
await requireV1()
|
||||||
|
return client(value).session.update({ sessionID, time: { archived: Date.now() } })
|
||||||
|
},
|
||||||
|
todo: async (sessionID: string, value?: string) => {
|
||||||
|
await requireV1()
|
||||||
|
return (await client(value).session.todo({ sessionID })).data ?? []
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
update: async (value: Parameters<LegacyClient["project"]["update"]>[0]) => {
|
||||||
|
await requireV1()
|
||||||
|
return client(value.directory).project.update(value)
|
||||||
|
},
|
||||||
|
initGit: async (value?: string) => {
|
||||||
|
await requireV1()
|
||||||
|
return client(value).project.initGit()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
reset: async (root: string, value: string) => {
|
||||||
|
await requireV1()
|
||||||
|
await client(value).instance.dispose().catch(() => undefined)
|
||||||
|
return client(root).worktree.reset({ worktreeResetInput: { directory: value } })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pty: {
|
||||||
|
shells: async () => {
|
||||||
|
await requireV1()
|
||||||
|
return (await client().pty.shells()).data ?? []
|
||||||
|
},
|
||||||
|
},
|
||||||
|
path: {
|
||||||
|
get: async (value?: string) => {
|
||||||
|
await requireV1()
|
||||||
|
return (await client(value).path.get()).data
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lsp: {
|
||||||
|
status: async (value: string) => {
|
||||||
|
await requireV1()
|
||||||
|
return (await client(value).lsp.status()).data ?? []
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LegacyCapabilities = ReturnType<typeof createLegacyCapabilities>
|
||||||
|
|
||||||
|
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 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 directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
|
||||||
|
const result = await legacy(value.location).worktree.list()
|
||||||
|
return (result.data ?? []).map((item) => ({ directory: item }))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
...input.current.location,
|
||||||
|
async get(value?: Parameters<ServerApi["location"]["get"]>[0]) {
|
||||||
|
const result = await legacy(value?.location).path.get()
|
||||||
|
if (!result.data) throw new Error("Location unavailable")
|
||||||
|
return {
|
||||||
|
directory: result.data.directory,
|
||||||
|
project: {
|
||||||
|
id: "",
|
||||||
|
directory: result.data.worktree,
|
||||||
|
canonical: result.data.worktree,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vcs: {
|
||||||
|
...input.current.vcs,
|
||||||
|
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 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 })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
permission: {
|
||||||
|
...input.current.permission,
|
||||||
|
request: {
|
||||||
|
...input.current.permission.request,
|
||||||
|
async list(value?: Parameters<ServerApi["permission"]["request"]["list"]>[0]) {
|
||||||
|
const result = await legacy(value?.location).permission.list()
|
||||||
|
return located(
|
||||||
|
(result.data ?? []).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
sessionID: item.sessionID,
|
||||||
|
action: item.permission,
|
||||||
|
resources: item.patterns,
|
||||||
|
metadata: item.metadata,
|
||||||
|
save: item.always,
|
||||||
|
source: item.tool
|
||||||
|
? { type: "tool" as const, messageID: item.tool.messageID, callID: item.tool.callID }
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
value?.location,
|
||||||
|
) as Awaited<ReturnType<ServerApi["permission"]["request"]["list"]>>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
request: {
|
||||||
|
...input.current.question.request,
|
||||||
|
async list(value?: Parameters<ServerApi["question"]["request"]["list"]>[0]) {
|
||||||
|
return located(
|
||||||
|
((await legacy(value?.location).question.list()).data ?? []).map((request) => ({
|
||||||
|
...request,
|
||||||
|
tool: request.tool && { messageID: request.tool.messageID, id: request.tool.callID },
|
||||||
|
})),
|
||||||
|
value?.location,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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,6 +29,32 @@ 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
|
||||||
@@ -146,7 +172,7 @@ describe("checkServerHealth", () => {
|
|||||||
retryDelayMs: 1,
|
retryDelayMs: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(count).toBe(3)
|
expect(count).toBe(6)
|
||||||
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 } from "./server"
|
import { authTokenFromCredentials, createSdkForServer } 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,7 +104,10 @@ 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 next(count, current.error)
|
return createSdkForServer({ server, fetch, signal })
|
||||||
|
.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?.())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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,3 +1,4 @@
|
|||||||
|
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"
|
||||||
@@ -17,6 +18,29 @@ 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,6 +3,31 @@ 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 } },
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
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,4 +20,61 @@ 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,17 +1,35 @@
|
|||||||
|
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 next = new URL(`${input.url}/api/pty/${input.id}/connect`)
|
const isV1 = input.protocol === "v1"
|
||||||
next.searchParams.set("location[directory]", input.directory)
|
const next = new URL(`${input.url}${isV1 ? `/pty/${input.id}/connect` : `/api/pty/${input.id}/connect`}`)
|
||||||
|
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 "@/types"
|
import type { Project } from "@opencode-ai/sdk/v2/client"
|
||||||
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"
|
||||||
|
|||||||
+193
-38
@@ -1,4 +1,4 @@
|
|||||||
import type { AgentSideConnection, PromptResponse } from "@agentclientprotocol/sdk"
|
import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||||
import type {
|
import type {
|
||||||
EventSubscribeOutput,
|
EventSubscribeOutput,
|
||||||
OpenCodeClient,
|
OpenCodeClient,
|
||||||
@@ -37,6 +37,34 @@ export type TurnStart =
|
|||||||
| { readonly type: "skill"; readonly id: string }
|
| { readonly type: "skill"; readonly id: string }
|
||||||
| { readonly type: "compaction"; readonly id: string }
|
| { readonly type: "compaction"; readonly id: string }
|
||||||
|
|
||||||
|
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
|
||||||
|
export const ChildSessionUpdateMethod = "opencode/session/child_update"
|
||||||
|
|
||||||
|
type ChildSessionUpdateBase = {
|
||||||
|
readonly rootSessionId: string
|
||||||
|
readonly childSessionId: string
|
||||||
|
readonly parentSessionId: string
|
||||||
|
readonly depth: number
|
||||||
|
readonly title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChildSessionEvent =
|
||||||
|
| { readonly type: "update"; readonly update: SessionUpdate }
|
||||||
|
| {
|
||||||
|
readonly type: "status"
|
||||||
|
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
|
||||||
|
readonly error?: { readonly type: string; readonly message: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChildSessionUpdate = ChildSessionUpdateBase & ChildSessionEvent
|
||||||
|
|
||||||
|
type ChildSession = {
|
||||||
|
readonly id: string
|
||||||
|
readonly parentID: string
|
||||||
|
readonly depth: number
|
||||||
|
readonly title?: string
|
||||||
|
}
|
||||||
|
|
||||||
function emptyToolState(): ToolState {
|
function emptyToolState(): ToolState {
|
||||||
return { name: "tool", input: {}, metadata: {}, content: [] }
|
return { name: "tool", input: {}, metadata: {}, content: [] }
|
||||||
}
|
}
|
||||||
@@ -50,8 +78,13 @@ export async function streamTurn(input: {
|
|||||||
readonly writeTextFile: boolean
|
readonly writeTextFile: boolean
|
||||||
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
||||||
readonly control: TurnControl
|
readonly control: TurnControl
|
||||||
|
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||||
|
readonly connectionSignal?: AbortSignal
|
||||||
|
readonly sessionSignal?: AbortSignal
|
||||||
}): Promise<PromptResponse> {
|
}): Promise<PromptResponse> {
|
||||||
const streamController = new AbortController()
|
const streamController = new AbortController()
|
||||||
|
const connectionAbort = () => streamController.abort()
|
||||||
|
input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true })
|
||||||
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
|
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
|
||||||
const connected = await stream.next()
|
const connected = await stream.next()
|
||||||
if (connected.done) throw new Error("event stream disconnected before prompt admission")
|
if (connected.done) throw new Error("event stream disconnected before prompt admission")
|
||||||
@@ -62,47 +95,101 @@ export async function streamTurn(input: {
|
|||||||
let finish: SessionMessageAssistant["finish"]
|
let finish: SessionMessageAssistant["finish"]
|
||||||
let executionError: { readonly type: string; readonly message: string } | undefined
|
let executionError: { readonly type: string; readonly message: string } | undefined
|
||||||
const tools = new Map<string, ToolState>()
|
const tools = new Map<string, ToolState>()
|
||||||
|
const children = new Map<string, ChildSession>()
|
||||||
|
const openChildren = new Set<string>()
|
||||||
|
let handedOff = false
|
||||||
|
|
||||||
const update = (value: Parameters<Connection["sessionUpdate"]>[0]["update"]) =>
|
const notifyChild = async (child: ChildSession, value: ChildSessionEvent) => {
|
||||||
input.connection.sessionUpdate({ sessionId: input.sessionID, update: value })
|
if (!input.childSessionUpdate) return
|
||||||
|
await input
|
||||||
|
.childSessionUpdate({
|
||||||
|
rootSessionId: input.sessionID,
|
||||||
|
childSessionId: child.id,
|
||||||
|
parentSessionId: child.parentID,
|
||||||
|
depth: child.depth,
|
||||||
|
...(child.title ? { title: child.title } : {}),
|
||||||
|
...value,
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
const consume = async () => {
|
const updateSession = async (value: SessionUpdate, child: ChildSession | undefined, mode: "turn" | "background") => {
|
||||||
|
const projected = child ? projectChildUpdate(value, child) : value
|
||||||
|
if (mode === "turn" && (!child || !input.childSessionUpdate)) {
|
||||||
|
await input.connection.sessionUpdate({ sessionId: input.sessionID, update: projected })
|
||||||
|
}
|
||||||
|
if (child) await notifyChild(child, { type: "update", update: projected })
|
||||||
|
}
|
||||||
|
|
||||||
|
const consume = async (mode: "turn" | "background") => {
|
||||||
while (!streamController.signal.aborted) {
|
while (!streamController.signal.aborted) {
|
||||||
const next = await stream.next()
|
const next = await stream.next()
|
||||||
if (next.done) throw new Error("event stream disconnected during prompt execution")
|
if (next.done) throw new Error("event stream disconnected during prompt execution")
|
||||||
const event = next.value
|
const event = next.value
|
||||||
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
|
if (event.type === "session.created") {
|
||||||
const tool = event.data.source?.id ? tools.get(event.data.source.id) : undefined
|
const parentID = event.data.info.parentID
|
||||||
|
if (!parentID) continue
|
||||||
|
const parent = parentID === input.sessionID ? undefined : children.get(parentID)
|
||||||
|
if ((mode === "turn" && parentID === input.sessionID) || parent) {
|
||||||
|
const child = {
|
||||||
|
id: event.data.sessionID,
|
||||||
|
parentID,
|
||||||
|
depth: parent ? parent.depth + 1 : 1,
|
||||||
|
title: event.data.info.title,
|
||||||
|
}
|
||||||
|
children.set(child.id, child)
|
||||||
|
openChildren.add(child.id)
|
||||||
|
await notifyChild(child, { type: "status", status: "created" })
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventSessionID = sessionIDFromEvent(event)
|
||||||
|
const child = eventSessionID ? children.get(eventSessionID) : undefined
|
||||||
|
const send = (update: SessionUpdate) => updateSession(update, child, mode)
|
||||||
|
if (mode === "background" && !child) continue
|
||||||
|
|
||||||
|
if (event.type === "permission.asked" && (event.data.sessionID === input.sessionID || child)) {
|
||||||
|
const tool = event.data.source?.id ? tools.get(toolKey(event.data.sessionID, event.data.source.id)) : undefined
|
||||||
await replyPermission({
|
await replyPermission({
|
||||||
client: input.client,
|
client: input.client,
|
||||||
connection: input.connection,
|
connection: input.connection,
|
||||||
event,
|
event,
|
||||||
sessionID: input.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
|
clientSessionID: input.sessionID,
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
tool,
|
tool,
|
||||||
|
...(child ? { toolCallPrefix: child.id, titlePrefix: child.title } : {}),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "form.created" && event.data.form.sessionID === input.sessionID) {
|
if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) {
|
||||||
await input.client.form
|
await input.client.form
|
||||||
.cancel({ sessionID: input.sessionID, formID: event.data.form.id })
|
.cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id })
|
||||||
.catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}))
|
.catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {}))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
|
if (!eventSessionID || (eventSessionID !== input.sessionID && !child)) continue
|
||||||
if (matchesStart(event, input.start)) {
|
if (matchesStart(event, input.start)) {
|
||||||
started = true
|
started = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!started) continue
|
if (!started) continue
|
||||||
|
|
||||||
|
if (event.type === "session.execution.started") {
|
||||||
|
if (child) {
|
||||||
|
await notifyChild(child, { type: "status", status: "running" })
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (event.type === "session.step.started") {
|
if (event.type === "session.step.started") {
|
||||||
assistantMessageID = event.data.assistantMessageID
|
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.text.delta") {
|
if (event.type === "session.text.delta") {
|
||||||
assistantMessageID = event.data.assistantMessageID
|
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||||
await update({
|
await send({
|
||||||
sessionUpdate: "agent_message_chunk",
|
sessionUpdate: "agent_message_chunk",
|
||||||
messageId: event.data.assistantMessageID,
|
messageId: event.data.assistantMessageID,
|
||||||
content: { type: "text", text: event.data.delta },
|
content: { type: "text", text: event.data.delta },
|
||||||
@@ -110,8 +197,8 @@ export async function streamTurn(input: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.reasoning.delta") {
|
if (event.type === "session.reasoning.delta") {
|
||||||
assistantMessageID = event.data.assistantMessageID
|
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||||
await update({
|
await send({
|
||||||
sessionUpdate: "agent_thought_chunk",
|
sessionUpdate: "agent_thought_chunk",
|
||||||
messageId: event.data.assistantMessageID,
|
messageId: event.data.assistantMessageID,
|
||||||
content: { type: "text", text: event.data.delta },
|
content: { type: "text", text: event.data.delta },
|
||||||
@@ -119,9 +206,14 @@ export async function streamTurn(input: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.tool.input.started") {
|
if (event.type === "session.tool.input.started") {
|
||||||
assistantMessageID = event.data.assistantMessageID
|
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||||
tools.set(event.data.id, { name: event.data.name, input: {}, metadata: {}, content: [] })
|
tools.set(toolKey(event.data.sessionID, event.data.id), {
|
||||||
await update({
|
name: event.data.name,
|
||||||
|
input: {},
|
||||||
|
metadata: {},
|
||||||
|
content: [],
|
||||||
|
})
|
||||||
|
await send({
|
||||||
sessionUpdate: "tool_call",
|
sessionUpdate: "tool_call",
|
||||||
...pendingToolCall({
|
...pendingToolCall({
|
||||||
toolCallId: event.data.id,
|
toolCallId: event.data.id,
|
||||||
@@ -133,11 +225,12 @@ export async function streamTurn(input: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.tool.called") {
|
if (event.type === "session.tool.called") {
|
||||||
assistantMessageID = event.data.assistantMessageID
|
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||||
const current = tools.get(event.data.id) ?? emptyToolState()
|
const key = toolKey(event.data.sessionID, event.data.id)
|
||||||
|
const current = tools.get(key) ?? emptyToolState()
|
||||||
current.input = event.data.input
|
current.input = event.data.input
|
||||||
tools.set(event.data.id, current)
|
tools.set(key, current)
|
||||||
await update({
|
await send({
|
||||||
sessionUpdate: "tool_call_update",
|
sessionUpdate: "tool_call_update",
|
||||||
...runningToolUpdate({
|
...runningToolUpdate({
|
||||||
toolCallId: event.data.id,
|
toolCallId: event.data.id,
|
||||||
@@ -149,10 +242,10 @@ export async function streamTurn(input: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.tool.progress") {
|
if (event.type === "session.tool.progress") {
|
||||||
const current = tools.get(event.data.id)
|
const current = tools.get(toolKey(event.data.sessionID, event.data.id))
|
||||||
if (!current) continue
|
if (!current) continue
|
||||||
current.metadata = event.data.metadata
|
current.metadata = event.data.metadata
|
||||||
await update({
|
await send({
|
||||||
sessionUpdate: "tool_call_update",
|
sessionUpdate: "tool_call_update",
|
||||||
...runningToolUpdate({
|
...runningToolUpdate({
|
||||||
toolCallId: event.data.id,
|
toolCallId: event.data.id,
|
||||||
@@ -164,8 +257,9 @@ export async function streamTurn(input: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.tool.success") {
|
if (event.type === "session.tool.success") {
|
||||||
const current = tools.get(event.data.id) ?? emptyToolState()
|
const key = toolKey(event.data.sessionID, event.data.id)
|
||||||
tools.delete(event.data.id)
|
const current = tools.get(key) ?? emptyToolState()
|
||||||
|
tools.delete(key)
|
||||||
await syncEditedFiles({
|
await syncEditedFiles({
|
||||||
connection: input.connection,
|
connection: input.connection,
|
||||||
writeTextFile: input.writeTextFile,
|
writeTextFile: input.writeTextFile,
|
||||||
@@ -175,7 +269,7 @@ export async function streamTurn(input: {
|
|||||||
toolInput: current.input,
|
toolInput: current.input,
|
||||||
metadata: event.data.metadata ?? {},
|
metadata: event.data.metadata ?? {},
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
await update({
|
await send({
|
||||||
sessionUpdate: "tool_call_update",
|
sessionUpdate: "tool_call_update",
|
||||||
...completedToolUpdate({
|
...completedToolUpdate({
|
||||||
toolCallId: event.data.id,
|
toolCallId: event.data.id,
|
||||||
@@ -188,9 +282,10 @@ export async function streamTurn(input: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.tool.failed") {
|
if (event.type === "session.tool.failed") {
|
||||||
const current = tools.get(event.data.id) ?? emptyToolState()
|
const key = toolKey(event.data.sessionID, event.data.id)
|
||||||
tools.delete(event.data.id)
|
const current = tools.get(key) ?? emptyToolState()
|
||||||
await update({
|
tools.delete(key)
|
||||||
|
await send({
|
||||||
sessionUpdate: "tool_call_update",
|
sessionUpdate: "tool_call_update",
|
||||||
...errorToolUpdate({
|
...errorToolUpdate({
|
||||||
toolCallId: event.data.id,
|
toolCallId: event.data.id,
|
||||||
@@ -205,13 +300,33 @@ export async function streamTurn(input: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.step.ended") {
|
if (event.type === "session.step.ended") {
|
||||||
assistantMessageID = event.data.assistantMessageID
|
if (!child) {
|
||||||
finish = event.data.finish
|
assistantMessageID = event.data.assistantMessageID
|
||||||
|
finish = event.data.finish
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (event.type === "session.execution.succeeded") {
|
||||||
|
if (!child) return "succeeded" as const
|
||||||
|
openChildren.delete(child.id)
|
||||||
|
await notifyChild(child, { type: "status", status: "completed" })
|
||||||
|
if (mode === "background" && openChildren.size === 0) return "succeeded" as const
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (event.type === "session.execution.interrupted") {
|
||||||
|
if (!child) return "interrupted" as const
|
||||||
|
openChildren.delete(child.id)
|
||||||
|
await notifyChild(child, { type: "status", status: "interrupted" })
|
||||||
|
if (mode === "background" && openChildren.size === 0) return "interrupted" as const
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.execution.succeeded") return "succeeded" as const
|
|
||||||
if (event.type === "session.execution.interrupted") return "interrupted" as const
|
|
||||||
if (event.type === "session.execution.failed") {
|
if (event.type === "session.execution.failed") {
|
||||||
|
if (child) {
|
||||||
|
openChildren.delete(child.id)
|
||||||
|
await notifyChild(child, { type: "status", status: "failed", error: event.data.error })
|
||||||
|
if (mode === "background" && openChildren.size === 0) return "failed" as const
|
||||||
|
continue
|
||||||
|
}
|
||||||
executionError = event.data.error
|
executionError = event.data.error
|
||||||
return "failed" as const
|
return "failed" as const
|
||||||
}
|
}
|
||||||
@@ -219,7 +334,13 @@ export async function streamTurn(input: {
|
|||||||
return "interrupted" as const
|
return "interrupted" as const
|
||||||
}
|
}
|
||||||
|
|
||||||
const completed = consume()
|
const completed = consume("turn")
|
||||||
|
const closeStream = async () => {
|
||||||
|
streamController.abort()
|
||||||
|
input.connectionSignal?.removeEventListener("abort", connectionAbort)
|
||||||
|
input.sessionSignal?.removeEventListener("abort", connectionAbort)
|
||||||
|
await stream.return?.(undefined).catch(() => {})
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await input.submit(control.admission.signal).catch((error) => {
|
await input.submit(control.admission.signal).catch((error) => {
|
||||||
if (!control.cancelled) throw error
|
if (!control.cancelled) throw error
|
||||||
@@ -233,6 +354,13 @@ export async function streamTurn(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const terminal = await completed
|
const terminal = await completed
|
||||||
|
if (input.childSessionUpdate && openChildren.size > 0 && !input.sessionSignal?.aborted) {
|
||||||
|
handedOff = true
|
||||||
|
input.sessionSignal?.addEventListener("abort", connectionAbort, { once: true })
|
||||||
|
void consume("background")
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(closeStream)
|
||||||
|
}
|
||||||
const assistant = assistantMessageID
|
const assistant = assistantMessageID
|
||||||
? await input.client.session
|
? await input.client.session
|
||||||
.message({ sessionID: input.sessionID, messageID: assistantMessageID })
|
.message({ sessionID: input.sessionID, messageID: assistantMessageID })
|
||||||
@@ -250,11 +378,38 @@ export async function streamTurn(input: {
|
|||||||
await completed.catch(() => {})
|
await completed.catch(() => {})
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
streamController.abort()
|
if (!handedOff) await closeStream()
|
||||||
await stream.return?.(undefined).catch(() => {})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionIDFromEvent(event: EventSubscribeOutput) {
|
||||||
|
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
|
||||||
|
if (event.type === "form.created") return event.data.form.sessionID
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolKey(sessionID: string, id: string) {
|
||||||
|
return `${sessionID}:${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||||
|
const projected = { ...update }
|
||||||
|
projected._meta = {
|
||||||
|
...projected._meta,
|
||||||
|
"opencode/child-session": {
|
||||||
|
id: child.id,
|
||||||
|
parentID: child.parentID,
|
||||||
|
depth: child.depth,
|
||||||
|
...(child.title ? { title: child.title } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
|
||||||
|
projected.toolCallId = `${child.id}:${projected.toolCallId}`
|
||||||
|
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
|
||||||
|
}
|
||||||
|
return projected
|
||||||
|
}
|
||||||
|
|
||||||
export async function replayMessages(
|
export async function replayMessages(
|
||||||
connection: Pick<AgentSideConnection, "sessionUpdate">,
|
connection: Pick<AgentSideConnection, "sessionUpdate">,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
|
|||||||
@@ -20,20 +20,28 @@ export async function replyPermission(input: {
|
|||||||
readonly connection: Connection
|
readonly connection: Connection
|
||||||
readonly event: PermissionEvent
|
readonly event: PermissionEvent
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
|
readonly clientSessionID?: string
|
||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
readonly tool?: Tool
|
readonly tool?: Tool
|
||||||
|
readonly toolCallPrefix?: string
|
||||||
|
readonly titlePrefix?: string
|
||||||
}) {
|
}) {
|
||||||
const toolName = input.tool?.name ?? input.event.data.action
|
const toolName = input.tool?.name ?? input.event.data.action
|
||||||
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
|
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
|
||||||
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
|
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
|
||||||
|
const toolCallID = input.event.data.source?.id ?? input.event.data.id
|
||||||
|
const title = permissionTitle(toolName, toolInput, previews)
|
||||||
const result = await input.connection
|
const result = await input.connection
|
||||||
.requestPermission({
|
.requestPermission({
|
||||||
sessionId: input.sessionID,
|
sessionId: input.clientSessionID ?? input.sessionID,
|
||||||
toolCall: {
|
toolCall: {
|
||||||
...pendingToolCall({
|
...pendingToolCall({
|
||||||
toolCallId: input.event.data.source?.id ?? input.event.data.id,
|
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
|
||||||
toolName,
|
toolName,
|
||||||
state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
|
state: {
|
||||||
|
input: toolInput,
|
||||||
|
title: prefixedTitle(input.titlePrefix, title),
|
||||||
|
},
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
}),
|
}),
|
||||||
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
|
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
|
||||||
@@ -51,6 +59,12 @@ export async function replyPermission(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||||
|
if (!prefix) return title
|
||||||
|
if (!title) return prefix
|
||||||
|
return `${prefix}: ${title}`
|
||||||
|
}
|
||||||
|
|
||||||
export async function syncEditedFiles(input: {
|
export async function syncEditedFiles(input: {
|
||||||
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
|
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
|
||||||
readonly writeTextFile: boolean
|
readonly writeTextFile: boolean
|
||||||
|
|||||||
@@ -43,13 +43,21 @@ import { OPENCODE_VERSION } from "../version"
|
|||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
|
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
|
||||||
import { promptContentToParts } from "./content"
|
import { promptContentToParts } from "./content"
|
||||||
import { replayMessages, streamTurn, type TurnControl, type TurnStart } from "./event"
|
import {
|
||||||
|
ChildSessionUpdateMethod,
|
||||||
|
ChildSessionUpdatesCapability,
|
||||||
|
replayMessages,
|
||||||
|
streamTurn,
|
||||||
|
type ChildSessionUpdate,
|
||||||
|
type TurnControl,
|
||||||
|
type TurnStart,
|
||||||
|
} from "./event"
|
||||||
import { ACPError } from "./error"
|
import { ACPError } from "./error"
|
||||||
|
|
||||||
export const AuthMethodID = "opencode-login"
|
export const AuthMethodID = "opencode-login"
|
||||||
|
|
||||||
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
|
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
|
||||||
Partial<Pick<AgentSideConnection, "writeTextFile">>
|
Partial<Pick<AgentSideConnection, "writeTextFile" | "extNotification" | "signal">>
|
||||||
|
|
||||||
type Catalog = {
|
type Catalog = {
|
||||||
readonly providers: ConfigOptionProvider[]
|
readonly providers: ConfigOptionProvider[]
|
||||||
@@ -64,6 +72,7 @@ type Catalog = {
|
|||||||
type Attached = {
|
type Attached = {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
|
readonly abort: AbortController
|
||||||
catalog: Catalog
|
catalog: Catalog
|
||||||
model: ModelRef
|
model: ModelRef
|
||||||
modeID: string
|
modeID: string
|
||||||
@@ -100,7 +109,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
const catalogs = new Map<string, Promise<Catalog>>()
|
const catalogs = new Map<string, Promise<Catalog>>()
|
||||||
const registeredMcp = new Map<string, Set<string>>()
|
const registeredMcp = new Map<string, Set<string>>()
|
||||||
const active = new Map<string, TurnControl>()
|
const active = new Map<string, TurnControl>()
|
||||||
const capabilities = { writeTextFile: false }
|
const capabilities = { writeTextFile: false, childSessionUpdates: false }
|
||||||
|
|
||||||
const catalog = (cwd: string) => {
|
const catalog = (cwd: string) => {
|
||||||
const cached = catalogs.get(cwd)
|
const cached = catalogs.get(cwd)
|
||||||
@@ -119,11 +128,19 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
|
throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const detach = (sessionID: string) => {
|
||||||
|
sessions.get(sessionID)?.abort.abort()
|
||||||
|
sessions.delete(sessionID)
|
||||||
|
registeredMcp.delete(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
|
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
|
||||||
const currentCatalog = await catalog(cwd)
|
const currentCatalog = await catalog(cwd)
|
||||||
|
sessions.get(session.id)?.abort.abort()
|
||||||
const state: Attached = {
|
const state: Attached = {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
cwd,
|
cwd,
|
||||||
|
abort: new AbortController(),
|
||||||
catalog: currentCatalog,
|
catalog: currentCatalog,
|
||||||
model: session.model ?? currentCatalog.defaultModel,
|
model: session.model ?? currentCatalog.defaultModel,
|
||||||
modeID: session.agent ?? currentCatalog.defaultModeID,
|
modeID: session.agent ?? currentCatalog.defaultModeID,
|
||||||
@@ -161,6 +178,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
return {
|
return {
|
||||||
initialize: async (params) => {
|
initialize: async (params) => {
|
||||||
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
|
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
|
||||||
|
capabilities.childSessionUpdates = params.clientCapabilities?._meta?.[ChildSessionUpdatesCapability] === true
|
||||||
const authMethod: AuthMethod = {
|
const authMethod: AuthMethod = {
|
||||||
description: "Run `opencode auth login` in the terminal",
|
description: "Run `opencode auth login` in the terminal",
|
||||||
name: "Login with opencode",
|
name: "Login with opencode",
|
||||||
@@ -178,6 +196,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
mcpCapabilities: { http: true, sse: false },
|
mcpCapabilities: { http: true, sse: false },
|
||||||
promptCapabilities: { embeddedContext: true, image: true },
|
promptCapabilities: { embeddedContext: true, image: true },
|
||||||
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
||||||
|
_meta: { [ChildSessionUpdatesCapability]: true },
|
||||||
},
|
},
|
||||||
authMethods: [authMethod],
|
authMethods: [authMethod],
|
||||||
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
|
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
|
||||||
@@ -224,8 +243,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
|
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
|
||||||
if (!isSessionNotFoundError(error)) throw error
|
if (!isSessionNotFoundError(error)) throw error
|
||||||
})
|
})
|
||||||
sessions.delete(params.sessionId)
|
detach(params.sessionId)
|
||||||
registeredMcp.delete(params.sessionId)
|
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
resumeSession: async (params) => {
|
resumeSession: async (params) => {
|
||||||
@@ -234,8 +252,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
return { configOptions: configOptions(state) }
|
return { configOptions: configOptions(state) }
|
||||||
},
|
},
|
||||||
closeSession: async (params) => {
|
closeSession: async (params) => {
|
||||||
sessions.delete(params.sessionId)
|
detach(params.sessionId)
|
||||||
registeredMcp.delete(params.sessionId)
|
|
||||||
const turn = active.get(params.sessionId)
|
const turn = active.get(params.sessionId)
|
||||||
if (turn) {
|
if (turn) {
|
||||||
turn.cancelled = true
|
turn.cancelled = true
|
||||||
@@ -296,6 +313,11 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
const messageID = SessionMessage.ID.create()
|
const messageID = SessionMessage.ID.create()
|
||||||
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
|
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
|
||||||
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
||||||
|
const extNotification = input.connection.extNotification
|
||||||
|
const childSessionUpdate =
|
||||||
|
capabilities.childSessionUpdates && extNotification
|
||||||
|
? (update: ChildSessionUpdate) => extNotification(ChildSessionUpdateMethod, update).then(() => {})
|
||||||
|
: undefined
|
||||||
active.set(state.id, control)
|
active.set(state.id, control)
|
||||||
const response = await streamTurn({
|
const response = await streamTurn({
|
||||||
client: input.client,
|
client: input.client,
|
||||||
@@ -305,7 +327,10 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
start: prepared.start,
|
start: prepared.start,
|
||||||
writeTextFile: capabilities.writeTextFile,
|
writeTextFile: capabilities.writeTextFile,
|
||||||
control,
|
control,
|
||||||
|
connectionSignal: input.connection.signal,
|
||||||
|
sessionSignal: state.abort.signal,
|
||||||
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
|
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
|
||||||
|
...(childSessionUpdate ? { childSessionUpdate } : {}),
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
if (active.get(state.id) === control) active.delete(state.id)
|
if (active.get(state.id) === control) active.delete(state.id)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export async function runMini(input: MiniCommandInput) {
|
|||||||
) =>
|
) =>
|
||||||
resolveSessionTarget({
|
resolveSessionTarget({
|
||||||
client,
|
client,
|
||||||
location: { directory: next.location.directory, workspace: next.location.workspaceID },
|
location: { directory: next.location.directory },
|
||||||
agent: next.agent,
|
agent: next.agent,
|
||||||
model: next.model
|
model: next.model
|
||||||
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
|
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
|
||||||
|
|||||||
@@ -703,7 +703,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
? Promise.resolve(undefined)
|
? Promise.resolve(undefined)
|
||||||
: input.client.form.request
|
: input.client.form.request
|
||||||
.list({
|
.list({
|
||||||
location: { directory: input.location.directory, workspace: input.location.workspaceID },
|
location: { directory: input.location.directory },
|
||||||
})
|
})
|
||||||
.catch(() => undefined),
|
.catch(() => undefined),
|
||||||
])
|
])
|
||||||
@@ -757,7 +757,6 @@ function formRequestOptions(location: LocationRef | undefined): [] | [{ headers:
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"x-opencode-directory": encodeURIComponent(location.directory),
|
"x-opencode-directory": encodeURIComponent(location.directory),
|
||||||
...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
|||||||
next.model ??
|
next.model ??
|
||||||
(options.variant
|
(options.variant
|
||||||
? await client.model
|
? await client.model
|
||||||
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
|
.default({ location: { directory: next.location.directory } })
|
||||||
.then((result) => result.data)
|
.then((result) => result.data)
|
||||||
: undefined)
|
: undefined)
|
||||||
const model = selected
|
const model = selected
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ async function latestSession(
|
|||||||
const page = await client.session.list(
|
const page = await client.session.list(
|
||||||
{
|
{
|
||||||
directory: location.directory,
|
directory: location.directory,
|
||||||
workspace: location.workspaceID,
|
|
||||||
parentID: null,
|
parentID: null,
|
||||||
limit: SESSION_PAGE_LIMIT,
|
limit: SESSION_PAGE_LIMIT,
|
||||||
order: "desc",
|
order: "desc",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
import { resolve } from "node:path"
|
import { resolve } from "node:path"
|
||||||
import { replayMessages, streamTurn, type TurnControl } from "../../src/acp/event"
|
import { replayMessages, streamTurn, type ChildSessionUpdate, type TurnControl } from "../../src/acp/event"
|
||||||
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
||||||
|
|
||||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||||
@@ -191,6 +191,181 @@ describe("acp event behavior", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("projects foreground child session updates onto the parent turn", async () => {
|
||||||
|
const updates: SessionUpdateParams[] = []
|
||||||
|
const fixture = createSseFixture({
|
||||||
|
onPrompt({ id, send }) {
|
||||||
|
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
|
||||||
|
send(
|
||||||
|
durableEvent("session.created", {
|
||||||
|
sessionID: "ses_child",
|
||||||
|
info: childSession("ses_child", "ses_parent", "Explore code"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
|
||||||
|
send(
|
||||||
|
durableEvent("session.tool.input.started", {
|
||||||
|
sessionID: "ses_child",
|
||||||
|
assistantMessageID: "msg_child",
|
||||||
|
id: "call_read",
|
||||||
|
name: "read",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
send(
|
||||||
|
durableEvent("session.tool.called", {
|
||||||
|
sessionID: "ses_child",
|
||||||
|
assistantMessageID: "msg_child",
|
||||||
|
id: "call_read",
|
||||||
|
input: { path: "/workspace/src/index.ts" },
|
||||||
|
executed: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
send(
|
||||||
|
durableEvent("session.tool.success", {
|
||||||
|
sessionID: "ses_child",
|
||||||
|
assistantMessageID: "msg_child",
|
||||||
|
id: "call_read",
|
||||||
|
metadata: {},
|
||||||
|
content: [{ type: "text", text: "source" }],
|
||||||
|
executed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
|
||||||
|
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await turn({
|
||||||
|
fixture,
|
||||||
|
connection: recordingConnection(updates),
|
||||||
|
sessionID: "ses_parent",
|
||||||
|
inputID: "input_parent",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([
|
||||||
|
["ses_parent", "tool_call"],
|
||||||
|
["ses_parent", "tool_call_update"],
|
||||||
|
["ses_parent", "tool_call_update"],
|
||||||
|
])
|
||||||
|
expect(updates.map((item) => ("toolCallId" in item.update ? item.update.toolCallId : undefined))).toEqual([
|
||||||
|
"ses_child:call_read",
|
||||||
|
"ses_child:call_read",
|
||||||
|
"ses_child:call_read",
|
||||||
|
])
|
||||||
|
expect(updates[0]?.update).toMatchObject({
|
||||||
|
title: "Explore code: read",
|
||||||
|
_meta: {
|
||||||
|
"opencode/child-session": {
|
||||||
|
id: "ses_child",
|
||||||
|
parentID: "ses_parent",
|
||||||
|
depth: 1,
|
||||||
|
title: "Explore code",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(response.stopReason).toBe("end_turn")
|
||||||
|
} finally {
|
||||||
|
await fixture.stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("continues child extension updates after the parent turn ends", async () => {
|
||||||
|
const updates: SessionUpdateParams[] = []
|
||||||
|
const childUpdates: ChildSessionUpdate[] = []
|
||||||
|
const completed = Promise.withResolvers<void>()
|
||||||
|
const fixture = createSseFixture({
|
||||||
|
onPrompt({ id, send }) {
|
||||||
|
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
|
||||||
|
send(
|
||||||
|
durableEvent("session.created", {
|
||||||
|
sessionID: "ses_background",
|
||||||
|
info: childSession("ses_background", "ses_parent", "Background research"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await turn({
|
||||||
|
fixture,
|
||||||
|
connection: recordingConnection(updates),
|
||||||
|
sessionID: "ses_parent",
|
||||||
|
inputID: "input_parent",
|
||||||
|
childSessionUpdate: async (update) => {
|
||||||
|
childUpdates.push(update)
|
||||||
|
if (update.type === "status" && update.status === "completed") completed.resolve()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(response.stopReason).toBe("end_turn")
|
||||||
|
|
||||||
|
fixture.send(
|
||||||
|
durableEvent("session.created", {
|
||||||
|
sessionID: "ses_future",
|
||||||
|
info: childSession("ses_future", "ses_parent", "Later turn child"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_future" }))
|
||||||
|
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_background" }))
|
||||||
|
fixture.send(
|
||||||
|
durableEvent("session.tool.input.started", {
|
||||||
|
sessionID: "ses_background",
|
||||||
|
assistantMessageID: "msg_background",
|
||||||
|
id: "call_shell",
|
||||||
|
name: "shell",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
fixture.send(
|
||||||
|
durableEvent("session.tool.called", {
|
||||||
|
sessionID: "ses_background",
|
||||||
|
assistantMessageID: "msg_background",
|
||||||
|
id: "call_shell",
|
||||||
|
input: { command: "pwd" },
|
||||||
|
executed: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
fixture.send(
|
||||||
|
durableEvent("session.tool.success", {
|
||||||
|
sessionID: "ses_background",
|
||||||
|
assistantMessageID: "msg_background",
|
||||||
|
id: "call_shell",
|
||||||
|
metadata: { exit: 0 },
|
||||||
|
content: [{ type: "text", text: "/workspace" }],
|
||||||
|
executed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
fixture.send(durableEvent("session.execution.succeeded", { sessionID: "ses_background" }))
|
||||||
|
await withTimeout(completed.promise, "background child completion was not delivered")
|
||||||
|
|
||||||
|
expect(updates).toEqual([])
|
||||||
|
expect(
|
||||||
|
childUpdates.map((update) =>
|
||||||
|
update.type === "status" ? [update.type, update.status] : [update.type, update.update.sessionUpdate],
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
["status", "created"],
|
||||||
|
["status", "running"],
|
||||||
|
["update", "tool_call"],
|
||||||
|
["update", "tool_call_update"],
|
||||||
|
["update", "tool_call_update"],
|
||||||
|
["status", "completed"],
|
||||||
|
])
|
||||||
|
expect(childUpdates[2]).toMatchObject({
|
||||||
|
rootSessionId: "ses_parent",
|
||||||
|
childSessionId: "ses_background",
|
||||||
|
parentSessionId: "ses_parent",
|
||||||
|
depth: 1,
|
||||||
|
title: "Background research",
|
||||||
|
type: "update",
|
||||||
|
update: { toolCallId: "ses_background:call_shell" },
|
||||||
|
})
|
||||||
|
expect(childUpdates.some((update) => update.childSessionId === "ses_future")).toBe(false)
|
||||||
|
} finally {
|
||||||
|
await fixture.stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("streams tool pending, progress, success, and failure updates", async () => {
|
test("streams tool pending, progress, success, and failure updates", async () => {
|
||||||
const updates: SessionUpdateParams[] = []
|
const updates: SessionUpdateParams[] = []
|
||||||
const fixture = createSseFixture({
|
const fixture = createSseFixture({
|
||||||
@@ -556,6 +731,7 @@ function turn(input: {
|
|||||||
readonly connection: Connection
|
readonly connection: Connection
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly inputID: string
|
readonly inputID: string
|
||||||
|
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
return streamTurn({
|
return streamTurn({
|
||||||
client: input.fixture.client,
|
client: input.fixture.client,
|
||||||
@@ -565,11 +741,25 @@ function turn(input: {
|
|||||||
start: { type: "input", id: input.inputID },
|
start: { type: "input", id: input.inputID },
|
||||||
writeTextFile: false,
|
writeTextFile: false,
|
||||||
control: { cancelled: false, admission: new AbortController() },
|
control: { cancelled: false, admission: new AbortController() },
|
||||||
|
childSessionUpdate: input.childSessionUpdate,
|
||||||
submit: (signal) =>
|
submit: (signal) =>
|
||||||
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
|
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function childSession(id: string, parentID: string, title: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
slug: id,
|
||||||
|
projectID: "project",
|
||||||
|
directory: "/workspace",
|
||||||
|
parentID,
|
||||||
|
title,
|
||||||
|
version: "test",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function tokens() {
|
function tokens() {
|
||||||
return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
|
return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,68 @@ describe("acp permission behavior", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("routes foreground child permissions through the parent ACP session", async () => {
|
||||||
|
const permissionRequests: RequestPermissionRequest[] = []
|
||||||
|
const fixture = createSseFixture({
|
||||||
|
onPrompt({ id, send }) {
|
||||||
|
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
|
||||||
|
send(
|
||||||
|
durableEvent("session.created", {
|
||||||
|
sessionID: "ses_child",
|
||||||
|
info: {
|
||||||
|
id: "ses_child",
|
||||||
|
slug: "ses_child",
|
||||||
|
projectID: "project",
|
||||||
|
directory: "/workspace",
|
||||||
|
parentID: "ses_parent",
|
||||||
|
title: "Review code",
|
||||||
|
version: "test",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
|
||||||
|
send(
|
||||||
|
permissionAsked("ses_child", "perm_child", {
|
||||||
|
action: "read",
|
||||||
|
metadata: { path: "/workspace/child.ts" },
|
||||||
|
source: { type: "tool", messageID: "msg_child", id: "call_child" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
|
||||||
|
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const connection = {
|
||||||
|
sessionUpdate: async () => {},
|
||||||
|
requestPermission: async (request) => {
|
||||||
|
permissionRequests.push(request)
|
||||||
|
return { outcome: { outcome: "selected", optionId: "once" } } as const
|
||||||
|
},
|
||||||
|
} satisfies Connection
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startTurn(fixture, connection, "ses_parent", "input_parent")
|
||||||
|
|
||||||
|
expect(permissionRequests).toHaveLength(1)
|
||||||
|
expect(permissionRequests[0]).toMatchObject({
|
||||||
|
sessionId: "ses_parent",
|
||||||
|
toolCall: {
|
||||||
|
toolCallId: "ses_child:call_child",
|
||||||
|
title: "Review code: /workspace/child.ts",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(fixture.requests).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/session/ses_child/permission/perm_child/reply",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await fixture.stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("previews edits during approval and syncs the completed file", async () => {
|
test("previews edits during approval and syncs the completed file", async () => {
|
||||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
|
||||||
const file = path.join(cwd, "file.ts")
|
const file = path.join(cwd, "file.ts")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||||
import { OpenCode } from "@opencode-ai/client/promise"
|
import { OpenCode } from "@opencode-ai/client/promise"
|
||||||
import { ACPService } from "../../src/acp/service"
|
import { ACPService } from "../../src/acp/service"
|
||||||
|
import { ChildSessionUpdatesCapability } from "../../src/acp/event"
|
||||||
|
|
||||||
describe("acp service", () => {
|
describe("acp service", () => {
|
||||||
test("creates a v2 session, registers mcp, and publishes commands", async () => {
|
test("creates a v2 session, registers mcp, and publishes commands", async () => {
|
||||||
@@ -39,11 +40,17 @@ describe("acp service", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const initialized = await service.initialize({
|
||||||
|
protocolVersion: 1,
|
||||||
|
clientCapabilities: { _meta: { [ChildSessionUpdatesCapability]: true } },
|
||||||
|
clientInfo: { name: "test", version: "1" },
|
||||||
|
})
|
||||||
const result = await service.newSession({
|
const result = await service.newSession({
|
||||||
cwd: "/workspace",
|
cwd: "/workspace",
|
||||||
mcpServers: [{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] }],
|
mcpServers: [{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] }],
|
||||||
})
|
})
|
||||||
expect(result.sessionId).toBe("ses_acp")
|
expect(result.sessionId).toBe("ses_acp")
|
||||||
|
expect(initialized.agentCapabilities?._meta).toEqual({ [ChildSessionUpdatesCapability]: true })
|
||||||
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
|
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
|
||||||
expect(requests).toContainEqual({
|
expect(requests).toContainEqual({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
|
|||||||
@@ -426,14 +426,13 @@ describe("runNonInteractivePrompt", () => {
|
|||||||
const globalOptions = {
|
const globalOptions = {
|
||||||
headers: {
|
headers: {
|
||||||
"x-opencode-directory": "%2Fwork%20tree",
|
"x-opencode-directory": "%2Fwork%20tree",
|
||||||
"x-opencode-workspace": "wrk_1",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
|
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
|
||||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
|
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
|
||||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
|
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
|
||||||
expect(sdk.form.request.list).toHaveBeenCalledWith({
|
expect(sdk.form.request.list).toHaveBeenCalledWith({
|
||||||
location: { directory: "/work tree", workspace: "wrk_1" },
|
location: { directory: "/work tree" },
|
||||||
})
|
})
|
||||||
expect(sdk.question.list).not.toHaveBeenCalled()
|
expect(sdk.question.list).not.toHaveBeenCalled()
|
||||||
expect(sdk.question.reject).not.toHaveBeenCalled()
|
expect(sdk.question.reject).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import type { Effect, Stream } from "effect"
|
|||||||
import type { Location } from "@opencode-ai/schema/location"
|
import type { Location } from "@opencode-ai/schema/location"
|
||||||
import type { Agent } from "@opencode-ai/schema/agent"
|
import type { Agent } from "@opencode-ai/schema/agent"
|
||||||
import type { Plugin } from "@opencode-ai/schema/plugin"
|
import type { Plugin } from "@opencode-ai/schema/plugin"
|
||||||
import type { Workspace } from "@opencode-ai/schema/workspace"
|
|
||||||
import type { Session } from "@opencode-ai/schema/session"
|
import type { Session } from "@opencode-ai/schema/session"
|
||||||
import type { AbsolutePath } from "@opencode-ai/schema/schema"
|
import type { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||||
import type { Project } from "@opencode-ai/schema/project"
|
import type { Project } from "@opencode-ai/schema/project"
|
||||||
import type { RelativePath } from "@opencode-ai/schema/schema"
|
import type { RelativePath } from "@opencode-ai/schema/schema"
|
||||||
import type { Brand } from "effect"
|
import type { Brand } from "effect"
|
||||||
import type { Model } from "@opencode-ai/schema/model"
|
import type { Model } from "@opencode-ai/schema/model"
|
||||||
|
import type { Workspace } from "@opencode-ai/schema/workspace"
|
||||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
|
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||||
import type { AgentAttachment } from "@opencode-ai/schema/prompt"
|
import type { AgentAttachment } from "@opencode-ai/schema/prompt"
|
||||||
@@ -58,9 +58,7 @@ export interface ServerApi<E = never> {
|
|||||||
readonly get: ServerGetOperation<E>
|
readonly get: ServerGetOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint2_0Input = {
|
export type Endpoint2_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint2_0Output = Location.Info
|
export type Endpoint2_0Output = Location.Info
|
||||||
export type LocationGetOperation<E = never> = (input?: Endpoint2_0Input) => Effect.Effect<Endpoint2_0Output, E>
|
export type LocationGetOperation<E = never> = (input?: Endpoint2_0Input) => Effect.Effect<Endpoint2_0Output, E>
|
||||||
|
|
||||||
@@ -68,15 +66,13 @@ export interface LocationApi<E = never> {
|
|||||||
readonly get: LocationGetOperation<E>
|
readonly get: LocationGetOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint3_0Input = {
|
export type Endpoint3_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint3_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Agent.Info> }
|
export type Endpoint3_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Agent.Info> }
|
||||||
export type AgentListOperation<E = never> = (input?: Endpoint3_0Input) => Effect.Effect<Endpoint3_0Output, E>
|
export type AgentListOperation<E = never> = (input?: Endpoint3_0Input) => Effect.Effect<Endpoint3_0Output, E>
|
||||||
|
|
||||||
export type Endpoint3_1Input = {
|
export type Endpoint3_1Input = {
|
||||||
readonly agentID: Agent.ID
|
readonly agentID: Agent.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint3_1Output = { readonly location: Location.Info; readonly data: Agent.Info }
|
export type Endpoint3_1Output = { readonly location: Location.Info; readonly data: Agent.Info }
|
||||||
export type AgentGetOperation<E = never> = (input: Endpoint3_1Input) => Effect.Effect<Endpoint3_1Output, E>
|
export type AgentGetOperation<E = never> = (input: Endpoint3_1Input) => Effect.Effect<Endpoint3_1Output, E>
|
||||||
@@ -86,9 +82,7 @@ export interface AgentApi<E = never> {
|
|||||||
readonly get: AgentGetOperation<E>
|
readonly get: AgentGetOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint4_0Input = {
|
export type Endpoint4_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint4_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
|
export type Endpoint4_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
|
||||||
export type PluginListOperation<E = never> = (input?: Endpoint4_0Input) => Effect.Effect<Endpoint4_0Output, E>
|
export type PluginListOperation<E = never> = (input?: Endpoint4_0Input) => Effect.Effect<Endpoint4_0Output, E>
|
||||||
|
|
||||||
@@ -97,7 +91,6 @@ export interface PluginApi<E = never> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint5_0Input = {
|
export type Endpoint5_0Input = {
|
||||||
readonly workspace?: Workspace.ID | undefined
|
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
@@ -420,6 +413,7 @@ export type Endpoint5_26Output =
|
|||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly delta: { readonly [x: string]: (string & Brand.Brand<"Instruction.Hash">) | "removed" }
|
readonly delta: { readonly [x: string]: (string & Brand.Brand<"Instruction.Hash">) | "removed" }
|
||||||
|
readonly text?: string | undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -897,15 +891,11 @@ export interface MessageApi<E = never> {
|
|||||||
readonly list: MessageListOperation<E>
|
readonly list: MessageListOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint7_0Input = {
|
export type Endpoint7_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint7_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Model.Info> }
|
export type Endpoint7_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Model.Info> }
|
||||||
export type ModelListOperation<E = never> = (input?: Endpoint7_0Input) => Effect.Effect<Endpoint7_0Output, E>
|
export type ModelListOperation<E = never> = (input?: Endpoint7_0Input) => Effect.Effect<Endpoint7_0Output, E>
|
||||||
|
|
||||||
export type Endpoint7_1Input = {
|
export type Endpoint7_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint7_1Output = { readonly location: Location.Info; readonly data: Model.Info | undefined }
|
export type Endpoint7_1Output = { readonly location: Location.Info; readonly data: Model.Info | undefined }
|
||||||
export type ModelDefaultOperation<E = never> = (input?: Endpoint7_1Input) => Effect.Effect<Endpoint7_1Output, E>
|
export type ModelDefaultOperation<E = never> = (input?: Endpoint7_1Input) => Effect.Effect<Endpoint7_1Output, E>
|
||||||
|
|
||||||
@@ -915,7 +905,7 @@ export interface ModelApi<E = never> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint8_0Input = {
|
export type Endpoint8_0Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly prompt: string
|
readonly prompt: string
|
||||||
readonly model?: Model.Ref | undefined
|
readonly model?: Model.Ref | undefined
|
||||||
}
|
}
|
||||||
@@ -926,15 +916,13 @@ export interface GenerateApi<E = never> {
|
|||||||
readonly text: GenerateTextOperation<E>
|
readonly text: GenerateTextOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint9_0Input = {
|
export type Endpoint9_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint9_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Provider.Info> }
|
export type Endpoint9_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Provider.Info> }
|
||||||
export type ProviderListOperation<E = never> = (input?: Endpoint9_0Input) => Effect.Effect<Endpoint9_0Output, E>
|
export type ProviderListOperation<E = never> = (input?: Endpoint9_0Input) => Effect.Effect<Endpoint9_0Output, E>
|
||||||
|
|
||||||
export type Endpoint9_1Input = {
|
export type Endpoint9_1Input = {
|
||||||
readonly providerID: Provider.ID
|
readonly providerID: Provider.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint9_1Output = { readonly location: Location.Info; readonly data: Provider.Info }
|
export type Endpoint9_1Output = { readonly location: Location.Info; readonly data: Provider.Info }
|
||||||
export type ProviderGetOperation<E = never> = (input: Endpoint9_1Input) => Effect.Effect<Endpoint9_1Output, E>
|
export type ProviderGetOperation<E = never> = (input: Endpoint9_1Input) => Effect.Effect<Endpoint9_1Output, E>
|
||||||
@@ -944,21 +932,19 @@ export interface ProviderApi<E = never> {
|
|||||||
readonly get: ProviderGetOperation<E>
|
readonly get: ProviderGetOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint10_0Input = {
|
export type Endpoint10_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint10_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Integration.Info> }
|
export type Endpoint10_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Integration.Info> }
|
||||||
export type IntegrationListOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E>
|
export type IntegrationListOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E>
|
||||||
|
|
||||||
export type Endpoint10_1Input = {
|
export type Endpoint10_1Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint10_1Output = { readonly location: Location.Info; readonly data: Integration.Info | undefined }
|
export type Endpoint10_1Output = { readonly location: Location.Info; readonly data: Integration.Info | undefined }
|
||||||
export type IntegrationGetOperation<E = never> = (input: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
|
export type IntegrationGetOperation<E = never> = (input: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
|
||||||
|
|
||||||
export type Endpoint10_2Input = {
|
export type Endpoint10_2Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly url: string
|
readonly url: string
|
||||||
}
|
}
|
||||||
export type Endpoint10_2Output = void
|
export type Endpoint10_2Output = void
|
||||||
@@ -968,7 +954,7 @@ export type IntegrationWellknownAddOperation<E = never> = (
|
|||||||
|
|
||||||
export type Endpoint10_3Input = {
|
export type Endpoint10_3Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly key: string
|
readonly key: string
|
||||||
readonly label?: string | undefined
|
readonly label?: string | undefined
|
||||||
}
|
}
|
||||||
@@ -979,7 +965,7 @@ export type IntegrationConnectKeyOperation<E = never> = (
|
|||||||
|
|
||||||
export type Endpoint10_4Input = {
|
export type Endpoint10_4Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly methodID: Integration.MethodID
|
readonly methodID: Integration.MethodID
|
||||||
readonly inputs: { readonly [x: string]: string }
|
readonly inputs: { readonly [x: string]: string }
|
||||||
readonly label?: string | undefined
|
readonly label?: string | undefined
|
||||||
@@ -992,7 +978,7 @@ export type IntegrationOauthConnectOperation<E = never> = (
|
|||||||
export type Endpoint10_5Input = {
|
export type Endpoint10_5Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly attemptID: Integration.AttemptID
|
readonly attemptID: Integration.AttemptID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint10_5Output = { readonly location: Location.Info; readonly data: Integration.AttemptStatus }
|
export type Endpoint10_5Output = { readonly location: Location.Info; readonly data: Integration.AttemptStatus }
|
||||||
export type IntegrationOauthStatusOperation<E = never> = (
|
export type IntegrationOauthStatusOperation<E = never> = (
|
||||||
@@ -1002,7 +988,7 @@ export type IntegrationOauthStatusOperation<E = never> = (
|
|||||||
export type Endpoint10_6Input = {
|
export type Endpoint10_6Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly attemptID: Integration.AttemptID
|
readonly attemptID: Integration.AttemptID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly code?: string | undefined
|
readonly code?: string | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint10_6Output = void
|
export type Endpoint10_6Output = void
|
||||||
@@ -1013,7 +999,7 @@ export type IntegrationOauthCompleteOperation<E = never> = (
|
|||||||
export type Endpoint10_7Input = {
|
export type Endpoint10_7Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly attemptID: Integration.AttemptID
|
readonly attemptID: Integration.AttemptID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint10_7Output = void
|
export type Endpoint10_7Output = void
|
||||||
export type IntegrationOauthCancelOperation<E = never> = (
|
export type IntegrationOauthCancelOperation<E = never> = (
|
||||||
@@ -1022,7 +1008,7 @@ export type IntegrationOauthCancelOperation<E = never> = (
|
|||||||
|
|
||||||
export type Endpoint10_8Input = {
|
export type Endpoint10_8Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly methodID: Integration.MethodID
|
readonly methodID: Integration.MethodID
|
||||||
readonly label?: string | undefined
|
readonly label?: string | undefined
|
||||||
}
|
}
|
||||||
@@ -1034,7 +1020,7 @@ export type IntegrationCommandConnectOperation<E = never> = (
|
|||||||
export type Endpoint10_9Input = {
|
export type Endpoint10_9Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly attemptID: Integration.AttemptID
|
readonly attemptID: Integration.AttemptID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint10_9Output = { readonly location: Location.Info; readonly data: Integration.CommandAttemptStatus }
|
export type Endpoint10_9Output = { readonly location: Location.Info; readonly data: Integration.CommandAttemptStatus }
|
||||||
export type IntegrationCommandStatusOperation<E = never> = (
|
export type IntegrationCommandStatusOperation<E = never> = (
|
||||||
@@ -1044,7 +1030,7 @@ export type IntegrationCommandStatusOperation<E = never> = (
|
|||||||
export type Endpoint10_10Input = {
|
export type Endpoint10_10Input = {
|
||||||
readonly integrationID: Integration.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly attemptID: Integration.AttemptID
|
readonly attemptID: Integration.AttemptID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint10_10Output = void
|
export type Endpoint10_10Output = void
|
||||||
export type IntegrationCommandCancelOperation<E = never> = (
|
export type IntegrationCommandCancelOperation<E = never> = (
|
||||||
@@ -1069,15 +1055,13 @@ export interface IntegrationApi<E = never> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint11_0Input = {
|
export type Endpoint11_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint11_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Mcp.Server> }
|
export type Endpoint11_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Mcp.Server> }
|
||||||
export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
|
export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
|
||||||
|
|
||||||
export type Endpoint11_1Input = {
|
export type Endpoint11_1Input = {
|
||||||
readonly server: string
|
readonly server: string
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly config: Mcp.LocalConfig | Mcp.RemoteConfig
|
readonly config: Mcp.LocalConfig | Mcp.RemoteConfig
|
||||||
}
|
}
|
||||||
export type Endpoint11_1Output = void
|
export type Endpoint11_1Output = void
|
||||||
@@ -1085,28 +1069,26 @@ export type McpAddOperation<E = never> = (input: Endpoint11_1Input) => Effect.Ef
|
|||||||
|
|
||||||
export type Endpoint11_2Input = {
|
export type Endpoint11_2Input = {
|
||||||
readonly server: string
|
readonly server: string
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint11_2Output = void
|
export type Endpoint11_2Output = void
|
||||||
export type McpRemoveOperation<E = never> = (input: Endpoint11_2Input) => Effect.Effect<Endpoint11_2Output, E>
|
export type McpRemoveOperation<E = never> = (input: Endpoint11_2Input) => Effect.Effect<Endpoint11_2Output, E>
|
||||||
|
|
||||||
export type Endpoint11_3Input = {
|
export type Endpoint11_3Input = {
|
||||||
readonly server: string
|
readonly server: string
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint11_3Output = void
|
export type Endpoint11_3Output = void
|
||||||
export type McpConnectOperation<E = never> = (input: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E>
|
export type McpConnectOperation<E = never> = (input: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E>
|
||||||
|
|
||||||
export type Endpoint11_4Input = {
|
export type Endpoint11_4Input = {
|
||||||
readonly server: string
|
readonly server: string
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint11_4Output = void
|
export type Endpoint11_4Output = void
|
||||||
export type McpDisconnectOperation<E = never> = (input: Endpoint11_4Input) => Effect.Effect<Endpoint11_4Output, E>
|
export type McpDisconnectOperation<E = never> = (input: Endpoint11_4Input) => Effect.Effect<Endpoint11_4Output, E>
|
||||||
|
|
||||||
export type Endpoint11_5Input = {
|
export type Endpoint11_5Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint11_5Output = { readonly location: Location.Info; readonly data: Mcp.ResourceCatalog }
|
export type Endpoint11_5Output = { readonly location: Location.Info; readonly data: Mcp.ResourceCatalog }
|
||||||
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_5Input) => Effect.Effect<Endpoint11_5Output, E>
|
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_5Input) => Effect.Effect<Endpoint11_5Output, E>
|
||||||
|
|
||||||
@@ -1121,7 +1103,7 @@ export interface McpApi<E = never> {
|
|||||||
|
|
||||||
export type Endpoint12_0Input = {
|
export type Endpoint12_0Input = {
|
||||||
readonly credentialID: Credential.ID
|
readonly credentialID: Credential.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly label: string
|
readonly label: string
|
||||||
}
|
}
|
||||||
export type Endpoint12_0Output = void
|
export type Endpoint12_0Output = void
|
||||||
@@ -1129,7 +1111,7 @@ export type CredentialUpdateOperation<E = never> = (input: Endpoint12_0Input) =>
|
|||||||
|
|
||||||
export type Endpoint12_1Input = {
|
export type Endpoint12_1Input = {
|
||||||
readonly credentialID: Credential.ID
|
readonly credentialID: Credential.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint12_1Output = void
|
export type Endpoint12_1Output = void
|
||||||
export type CredentialRemoveOperation<E = never> = (input: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E>
|
export type CredentialRemoveOperation<E = never> = (input: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E>
|
||||||
@@ -1142,15 +1124,13 @@ export interface CredentialApi<E = never> {
|
|||||||
export type Endpoint13_0Output = ReadonlyArray<Project.Info>
|
export type Endpoint13_0Output = ReadonlyArray<Project.Info>
|
||||||
export type ProjectListOperation<E = never> = () => Effect.Effect<Endpoint13_0Output, E>
|
export type ProjectListOperation<E = never> = () => Effect.Effect<Endpoint13_0Output, E>
|
||||||
|
|
||||||
export type Endpoint13_1Input = {
|
export type Endpoint13_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint13_1Output = Project.Current
|
export type Endpoint13_1Output = Project.Current
|
||||||
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
|
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
|
||||||
|
|
||||||
export type Endpoint13_2Input = {
|
export type Endpoint13_2Input = {
|
||||||
readonly projectID: Project.ID
|
readonly projectID: Project.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint13_2Output = Project.Directories
|
export type Endpoint13_2Output = Project.Directories
|
||||||
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
|
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
|
||||||
@@ -1161,9 +1141,7 @@ export interface ProjectApi<E = never> {
|
|||||||
readonly directories: ProjectDirectoriesOperation<E>
|
readonly directories: ProjectDirectoriesOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint14_0Input = {
|
export type Endpoint14_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint14_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Form.Info> }
|
export type Endpoint14_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Form.Info> }
|
||||||
export type FormRequestListOperation<E = never> = (input?: Endpoint14_0Input) => Effect.Effect<Endpoint14_0Output, E>
|
export type FormRequestListOperation<E = never> = (input?: Endpoint14_0Input) => Effect.Effect<Endpoint14_0Output, E>
|
||||||
|
|
||||||
@@ -1207,9 +1185,7 @@ export interface FormApi<E = never> {
|
|||||||
readonly cancel: FormCancelOperation<E>
|
readonly cancel: FormCancelOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint15_0Input = {
|
export type Endpoint15_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint15_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Permission.Request> }
|
export type Endpoint15_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Permission.Request> }
|
||||||
export type PermissionRequestListOperation<E = never> = (
|
export type PermissionRequestListOperation<E = never> = (
|
||||||
input?: Endpoint15_0Input,
|
input?: Endpoint15_0Input,
|
||||||
@@ -1267,14 +1243,14 @@ export interface PermissionApi<E = never> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint16_0Input = {
|
export type Endpoint16_0Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly path?: RelativePath | undefined
|
readonly path?: RelativePath | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint16_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
|
export type Endpoint16_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
|
||||||
export type FileListOperation<E = never> = (input?: Endpoint16_0Input) => Effect.Effect<Endpoint16_0Output, E>
|
export type FileListOperation<E = never> = (input?: Endpoint16_0Input) => Effect.Effect<Endpoint16_0Output, E>
|
||||||
|
|
||||||
export type Endpoint16_1Input = {
|
export type Endpoint16_1Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly query: string
|
readonly query: string
|
||||||
readonly type?: "file" | "directory" | undefined
|
readonly type?: "file" | "directory" | undefined
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
@@ -1287,9 +1263,7 @@ export interface FileApi<E = never> {
|
|||||||
readonly find: FileFindOperation<E>
|
readonly find: FileFindOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint17_0Input = {
|
export type Endpoint17_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint17_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Command.Info> }
|
export type Endpoint17_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Command.Info> }
|
||||||
export type CommandListOperation<E = never> = (input?: Endpoint17_0Input) => Effect.Effect<Endpoint17_0Output, E>
|
export type CommandListOperation<E = never> = (input?: Endpoint17_0Input) => Effect.Effect<Endpoint17_0Output, E>
|
||||||
|
|
||||||
@@ -1297,9 +1271,7 @@ export interface CommandApi<E = never> {
|
|||||||
readonly list: CommandListOperation<E>
|
readonly list: CommandListOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint18_0Input = {
|
export type Endpoint18_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint18_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Skill.Info> }
|
export type Endpoint18_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Skill.Info> }
|
||||||
export type SkillListOperation<E = never> = (input?: Endpoint18_0Input) => Effect.Effect<Endpoint18_0Output, E>
|
export type SkillListOperation<E = never> = (input?: Endpoint18_0Input) => Effect.Effect<Endpoint18_0Output, E>
|
||||||
|
|
||||||
@@ -1314,14 +1286,12 @@ export interface EventApi<E = never> {
|
|||||||
readonly subscribe: EventSubscribeOperation<E>
|
readonly subscribe: EventSubscribeOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint20_0Input = {
|
export type Endpoint20_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint20_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Pty.Info> }
|
export type Endpoint20_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Pty.Info> }
|
||||||
export type PtyListOperation<E = never> = (input?: Endpoint20_0Input) => Effect.Effect<Endpoint20_0Output, E>
|
export type PtyListOperation<E = never> = (input?: Endpoint20_0Input) => Effect.Effect<Endpoint20_0Output, E>
|
||||||
|
|
||||||
export type Endpoint20_1Input = {
|
export type Endpoint20_1Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly command?: string | undefined
|
readonly command?: string | undefined
|
||||||
readonly args?: ReadonlyArray<string> | undefined
|
readonly args?: ReadonlyArray<string> | undefined
|
||||||
readonly cwd?: string | undefined
|
readonly cwd?: string | undefined
|
||||||
@@ -1333,14 +1303,14 @@ export type PtyCreateOperation<E = never> = (input?: Endpoint20_1Input) => Effec
|
|||||||
|
|
||||||
export type Endpoint20_2Input = {
|
export type Endpoint20_2Input = {
|
||||||
readonly ptyID: Pty.ID
|
readonly ptyID: Pty.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint20_2Output = { readonly location: Location.Info; readonly data: Pty.Info }
|
export type Endpoint20_2Output = { readonly location: Location.Info; readonly data: Pty.Info }
|
||||||
export type PtyGetOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
|
export type PtyGetOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
|
||||||
|
|
||||||
export type Endpoint20_3Input = {
|
export type Endpoint20_3Input = {
|
||||||
readonly ptyID: Pty.ID
|
readonly ptyID: Pty.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly title?: string | undefined
|
readonly title?: string | undefined
|
||||||
readonly size?: { readonly rows: number; readonly cols: number } | undefined
|
readonly size?: { readonly rows: number; readonly cols: number } | undefined
|
||||||
}
|
}
|
||||||
@@ -1349,7 +1319,7 @@ export type PtyUpdateOperation<E = never> = (input: Endpoint20_3Input) => Effect
|
|||||||
|
|
||||||
export type Endpoint20_4Input = {
|
export type Endpoint20_4Input = {
|
||||||
readonly ptyID: Pty.ID
|
readonly ptyID: Pty.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint20_4Output = void
|
export type Endpoint20_4Output = void
|
||||||
export type PtyRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
|
export type PtyRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
|
||||||
@@ -1362,14 +1332,12 @@ export interface PtyApi<E = never> {
|
|||||||
readonly remove: PtyRemoveOperation<E>
|
readonly remove: PtyRemoveOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint21_0Input = {
|
export type Endpoint21_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint21_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> }
|
export type Endpoint21_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> }
|
||||||
export type ShellListOperation<E = never> = (input?: Endpoint21_0Input) => Effect.Effect<Endpoint21_0Output, E>
|
export type ShellListOperation<E = never> = (input?: Endpoint21_0Input) => Effect.Effect<Endpoint21_0Output, E>
|
||||||
|
|
||||||
export type Endpoint21_1Input = {
|
export type Endpoint21_1Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly command: string
|
readonly command: string
|
||||||
readonly cwd?: string | undefined
|
readonly cwd?: string | undefined
|
||||||
readonly timeout: number
|
readonly timeout: number
|
||||||
@@ -1380,14 +1348,14 @@ export type ShellCreateOperation<E = never> = (input: Endpoint21_1Input) => Effe
|
|||||||
|
|
||||||
export type Endpoint21_2Input = {
|
export type Endpoint21_2Input = {
|
||||||
readonly id: Shell.ID
|
readonly id: Shell.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint21_2Output = { readonly location: Location.Info; readonly data: Shell.Info }
|
export type Endpoint21_2Output = { readonly location: Location.Info; readonly data: Shell.Info }
|
||||||
export type ShellGetOperation<E = never> = (input: Endpoint21_2Input) => Effect.Effect<Endpoint21_2Output, E>
|
export type ShellGetOperation<E = never> = (input: Endpoint21_2Input) => Effect.Effect<Endpoint21_2Output, E>
|
||||||
|
|
||||||
export type Endpoint21_3Input = {
|
export type Endpoint21_3Input = {
|
||||||
readonly id: Shell.ID
|
readonly id: Shell.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly timeout: number
|
readonly timeout: number
|
||||||
}
|
}
|
||||||
export type Endpoint21_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
|
export type Endpoint21_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
|
||||||
@@ -1395,7 +1363,7 @@ export type ShellTimeoutOperation<E = never> = (input: Endpoint21_3Input) => Eff
|
|||||||
|
|
||||||
export type Endpoint21_4Input = {
|
export type Endpoint21_4Input = {
|
||||||
readonly id: Shell.ID
|
readonly id: Shell.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly cursor?: number | undefined
|
readonly cursor?: number | undefined
|
||||||
readonly limit?: number | undefined
|
readonly limit?: number | undefined
|
||||||
}
|
}
|
||||||
@@ -1412,7 +1380,7 @@ export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effe
|
|||||||
|
|
||||||
export type Endpoint21_5Input = {
|
export type Endpoint21_5Input = {
|
||||||
readonly id: Shell.ID
|
readonly id: Shell.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint21_5Output = void
|
export type Endpoint21_5Output = void
|
||||||
export type ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E>
|
export type ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E>
|
||||||
@@ -1426,9 +1394,7 @@ export interface ShellApi<E = never> {
|
|||||||
readonly remove: ShellRemoveOperation<E>
|
readonly remove: ShellRemoveOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint22_0Input = {
|
export type Endpoint22_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> }
|
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> }
|
||||||
export type QuestionRequestListOperation<E = never> = (
|
export type QuestionRequestListOperation<E = never> = (
|
||||||
input?: Endpoint22_0Input,
|
input?: Endpoint22_0Input,
|
||||||
@@ -1457,9 +1423,7 @@ export interface QuestionApi<E = never> {
|
|||||||
readonly reject: QuestionRejectOperation<E>
|
readonly reject: QuestionRejectOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint23_0Input = {
|
export type Endpoint23_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
|
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
|
||||||
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
|
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
|
||||||
|
|
||||||
@@ -1469,7 +1433,7 @@ export interface ReferenceApi<E = never> {
|
|||||||
|
|
||||||
export type Endpoint24_0Input = {
|
export type Endpoint24_0Input = {
|
||||||
readonly projectID: Project.ID
|
readonly projectID: Project.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly strategy: ProjectCopy.StrategyID
|
readonly strategy: ProjectCopy.StrategyID
|
||||||
readonly directory: AbsolutePath
|
readonly directory: AbsolutePath
|
||||||
readonly name?: string | undefined
|
readonly name?: string | undefined
|
||||||
@@ -1479,7 +1443,7 @@ export type ProjectCopyCreateOperation<E = never> = (input: Endpoint24_0Input) =
|
|||||||
|
|
||||||
export type Endpoint24_1Input = {
|
export type Endpoint24_1Input = {
|
||||||
readonly projectID: Project.ID
|
readonly projectID: Project.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly directory: AbsolutePath
|
readonly directory: AbsolutePath
|
||||||
readonly force: boolean
|
readonly force: boolean
|
||||||
}
|
}
|
||||||
@@ -1488,7 +1452,7 @@ export type ProjectCopyRemoveOperation<E = never> = (input: Endpoint24_1Input) =
|
|||||||
|
|
||||||
export type Endpoint24_2Input = {
|
export type Endpoint24_2Input = {
|
||||||
readonly projectID: Project.ID
|
readonly projectID: Project.ID
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint24_2Output = void
|
export type Endpoint24_2Output = void
|
||||||
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||||
@@ -1499,20 +1463,16 @@ export interface ProjectCopyApi<E = never> {
|
|||||||
readonly refresh: ProjectCopyRefreshOperation<E>
|
readonly refresh: ProjectCopyRefreshOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint25_0Input = {
|
export type Endpoint25_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
||||||
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
|
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
|
||||||
|
|
||||||
export type Endpoint25_1Input = {
|
export type Endpoint25_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
||||||
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||||
|
|
||||||
export type Endpoint25_2Input = {
|
export type Endpoint25_2Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly mode: Vcs.Mode
|
readonly mode: Vcs.Mode
|
||||||
readonly context?: number | undefined
|
readonly context?: number | undefined
|
||||||
}
|
}
|
||||||
@@ -1528,9 +1488,7 @@ export interface VcsApi<E = never> {
|
|||||||
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
|
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
|
||||||
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
||||||
|
|
||||||
export type Endpoint26_1Input = {
|
export type Endpoint26_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint26_1Output = void
|
export type Endpoint26_1Output = void
|
||||||
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
|
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
|
||||||
|
|
||||||
@@ -1538,14 +1496,12 @@ export interface DebugApi<E = never> {
|
|||||||
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint27_0Input = {
|
export type Endpoint27_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint27_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
export type Endpoint27_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
||||||
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
|
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
|
||||||
|
|
||||||
export type Endpoint27_1Input = {
|
export type Endpoint27_1Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||||
readonly query: string
|
readonly query: string
|
||||||
readonly providerID?: WebSearch.ID | undefined
|
readonly providerID?: WebSearch.ID | undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,7 +285,6 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
|
|||||||
preserveEffect<Endpoint5_0Output>()(
|
preserveEffect<Endpoint5_0Output>()(
|
||||||
raw["session.list"]({
|
raw["session.list"]({
|
||||||
query: {
|
query: {
|
||||||
workspace: input?.["workspace"],
|
|
||||||
limit: input?.["limit"],
|
limit: input?.["limit"],
|
||||||
order: input?.["order"],
|
order: input?.["order"],
|
||||||
search: input?.["search"],
|
search: input?.["search"],
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user