mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-29 14:41:48 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4105e04e5 | |||
| 689fb214e0 |
@@ -64,7 +64,6 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
@@ -4069,10 +4068,10 @@
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
@@ -6327,8 +6326,6 @@
|
||||
|
||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@opencode-ai/app/@opencode-ai/sdk": ["@opencode-ai/sdk@vendor/opencode-ai-sdk-1.18.8-dev.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-C2nfk4x0sPINwE5V6DPkFSuH3PkUmKPWHPzxpXC1j+3Ui5hslLCWJbkk8WcOG1Lyt3C0+yp4ea64v/kmtYCO4w=="],
|
||||
|
||||
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
# V1 API Migration Checklist
|
||||
|
||||
The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
|
||||
|
||||
## Events
|
||||
|
||||
- [x] Replace `GET /global/event` with `GET /api/event`.
|
||||
- `src/context/server-sdk.tsx`
|
||||
- [x] Reduce current granular session and message events into the existing app projections.
|
||||
- `src/context/server-session-v2-reducer.ts`
|
||||
- `src/context/server-session.ts`
|
||||
- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
- `src/context/server-session.ts`
|
||||
- `src/context/notification.tsx`
|
||||
- `src/pages/session/usage-exceeded-dialogs.tsx`
|
||||
- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
- `src/context/server-session.ts`
|
||||
- [x] Adapt current permission and question events to the existing request model.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
- `src/context/permission.tsx`
|
||||
- [x] Consume current file watcher events.
|
||||
- `src/context/file.tsx`
|
||||
- [x] Consume current VCS events.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Consume current `pty.exited` events.
|
||||
- `src/context/terminal.tsx`
|
||||
- [ ] Migrate LSP and reference events.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
|
||||
## Sessions
|
||||
|
||||
- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Migrate session listing from `GET /session`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- `src/context/directory-sync.ts`
|
||||
- `src/pages/layout.tsx`
|
||||
- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
|
||||
- `src/components/titlebar.tsx`
|
||||
- [x] Migrate session updates from `PATCH /session/:sessionID`.
|
||||
- `src/context/directory-sync.ts`
|
||||
- `src/context/layout.tsx`
|
||||
- `src/pages/home.tsx`
|
||||
- `src/pages/layout.tsx`
|
||||
- `src/pages/session/timeline/message-timeline.tsx`
|
||||
- `src/components/titlebar-tab-nav.tsx`
|
||||
- Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
|
||||
- [x] Migrate session deletion from `DELETE /session/:sessionID`.
|
||||
- `src/pages/session/timeline/message-timeline.tsx`
|
||||
- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
|
||||
- Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
|
||||
- [x] Migrate abort from `POST /session/:sessionID/abort`.
|
||||
- `src/components/prompt-input/submit.ts`
|
||||
- `src/pages/session/use-session-commands.tsx`
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
|
||||
- `src/pages/session/use-session-commands.tsx`
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
|
||||
- `src/pages/session/use-session-commands.tsx`
|
||||
- [x] Migrate slash commands from `POST /session/:sessionID/command`.
|
||||
- `src/components/prompt-input/submit.ts`
|
||||
- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
|
||||
- `src/components/prompt-input/submit.ts`
|
||||
- [x] Migrate session fork from `POST /session/:sessionID/fork`.
|
||||
- `src/components/dialog-fork.tsx`
|
||||
- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
|
||||
- `src/pages/session/use-session-commands.tsx`
|
||||
- `src/pages/session/timeline/message-timeline.tsx`
|
||||
- Blocked: the current API has no sharing contract or implementation.
|
||||
|
||||
## Session Compatibility Fallbacks
|
||||
|
||||
These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
|
||||
|
||||
- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
|
||||
- `src/context/server-session.ts`
|
||||
- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
|
||||
- `src/context/server-session.ts`
|
||||
- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
|
||||
- `src/context/server-session.ts`
|
||||
|
||||
## Filesystem
|
||||
|
||||
- [ ] Migrate file listing from `GET /file`.
|
||||
- `src/context/file.tsx`
|
||||
- [ ] Migrate file reads from `GET /file/content`.
|
||||
- `src/context/file.tsx`
|
||||
- `src/pages/session/review-tab.tsx`
|
||||
- `src/pages/session/v2/review-panel-v2.tsx`
|
||||
- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- `src/components/dialog-select-directory.tsx`
|
||||
- `src/components/dialog-select-directory-v2.tsx`
|
||||
|
||||
## Projects And Worktrees
|
||||
|
||||
- [x] Migrate project listing from `GET /project` to `GET /api/project`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [ ] Migrate Git initialization from `POST /project/git/init`.
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
|
||||
- `src/context/layout.tsx`
|
||||
- `src/components/edit-project.ts`
|
||||
- `src/pages/layout.tsx`
|
||||
- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
|
||||
- `src/pages/layout.tsx`
|
||||
- `src/components/prompt-input/submit.ts`
|
||||
- Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
|
||||
- [ ] Migrate instance disposal from `POST /instance/dispose`.
|
||||
- `src/pages/layout.tsx`
|
||||
|
||||
## VCS
|
||||
|
||||
- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
|
||||
- `src/pages/layout.tsx`
|
||||
|
||||
## Configuration And Authentication
|
||||
|
||||
- [ ] Migrate global configuration reads from `GET /global/config`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [ ] Migrate directory configuration reads from `GET /config`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [ ] Migrate global configuration updates from `PATCH /global/config`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
|
||||
- `src/components/dialog-connect-provider.tsx`
|
||||
- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
|
||||
- `src/components/dialog-connect-provider.tsx`
|
||||
- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
|
||||
- Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
|
||||
- `src/components/dialog-connect-provider.tsx`
|
||||
- `src/components/dialog-custom-provider.tsx`
|
||||
- `src/components/settings-providers.tsx`
|
||||
- `src/components/settings-v2/providers.tsx`
|
||||
- [ ] Migrate global disposal from `POST /global/dispose`.
|
||||
- `src/components/dialog-connect-provider.tsx`
|
||||
- `src/components/settings-providers.tsx`
|
||||
- `src/components/settings-v2/providers.tsx`
|
||||
|
||||
## Permissions And Questions
|
||||
|
||||
- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- `src/context/permission.tsx`
|
||||
- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
|
||||
- `src/context/permission.tsx`
|
||||
- `src/pages/session/composer/session-composer-state.ts`
|
||||
- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
|
||||
- `src/pages/session/composer/session-question-dock.tsx`
|
||||
|
||||
## Commands, MCP, LSP, And References
|
||||
|
||||
- [x] Migrate command listing from `GET /command` to `GET /api/command`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [ ] Migrate LSP status from `GET /lsp`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Move `GET /api/reference` off the legacy generated SDK transport.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
|
||||
## Search
|
||||
|
||||
- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
|
||||
- `src/components/command-palette.ts`
|
||||
- `src/components/dialog-command-palette-v2.tsx`
|
||||
|
||||
## PTY And Terminal
|
||||
|
||||
- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
|
||||
- `src/context/terminal.tsx`
|
||||
- `src/components/terminal.tsx`
|
||||
- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
|
||||
- `src/components/settings-general.tsx`
|
||||
- `src/components/settings-v2/general.tsx`
|
||||
- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
|
||||
- `src/components/terminal.tsx`
|
||||
- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
|
||||
- `src/components/terminal.tsx`
|
||||
|
||||
## Legacy Types And Adapters
|
||||
|
||||
These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
|
||||
|
||||
- [ ] Replace the current-session-to-legacy-session adapter.
|
||||
- `src/utils/session.ts`
|
||||
- [ ] Replace the current-message-to-legacy-message-and-part adapter.
|
||||
- `src/utils/session-message.ts`
|
||||
- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
|
||||
- `src/context/global-sync/utils.ts`
|
||||
- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
|
||||
- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
|
||||
- `package.json`
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
- [ ] Replace V1 endpoint mocks with current API mocks.
|
||||
- `e2e/utils/mock-server.ts`
|
||||
- [x] Replace `/global/event` and `/event` interception with current event transport handling.
|
||||
- `e2e/utils/sse-transport.ts`
|
||||
- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
|
||||
- `e2e/performance/timeline-stability/fixture.ts`
|
||||
- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.
|
||||
@@ -2,17 +2,8 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import type {
|
||||
AssistantMessage,
|
||||
GlobalEvent,
|
||||
Message,
|
||||
Part,
|
||||
Session,
|
||||
SessionStatus,
|
||||
ToolPart,
|
||||
ToolState,
|
||||
UserMessage,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionV1Info, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { Schema } from "effect"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
@@ -27,18 +18,29 @@ export const assistantID = "msg_1001_timeline_assistant"
|
||||
export const title = "Timeline visual stability"
|
||||
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type TimelinePayload = Extract<
|
||||
GlobalEvent["payload"],
|
||||
{
|
||||
type:
|
||||
| "message.updated"
|
||||
| "message.removed"
|
||||
| "message.part.updated"
|
||||
| "message.part.removed"
|
||||
| "message.part.delta"
|
||||
| "session.status"
|
||||
type Session = SessionV1Info
|
||||
type GlobalEvent = {
|
||||
directory: string
|
||||
project?: string
|
||||
workspace?: string
|
||||
payload: {
|
||||
id: string
|
||||
type: string
|
||||
properties: Record<string, unknown>
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
type TimelineProperties = {
|
||||
"message.updated": { sessionID: string; info: Message }
|
||||
"message.removed": { sessionID: string; messageID: string }
|
||||
"message.part.updated": { sessionID: string; part: Part; time: number }
|
||||
"message.part.removed": { sessionID: string; messageID: string; partID: string }
|
||||
"message.part.delta": { sessionID: string; messageID: string; partID: string; field: string; delta: string }
|
||||
"session.status": { sessionID: string; status: SessionStatus }
|
||||
}
|
||||
type TimelinePayload = {
|
||||
[Type in keyof TimelineProperties]: { id: string; type: Type; properties: TimelineProperties[Type] }
|
||||
}[keyof TimelineProperties]
|
||||
|
||||
type DeepReadonly<Value> = Value extends readonly unknown[]
|
||||
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
|
||||
@@ -97,7 +99,6 @@ export async function setupTimeline(
|
||||
locale?: string
|
||||
deviceScaleFactor?: number
|
||||
seedHistory?: boolean
|
||||
protocol?: "v1" | "v2"
|
||||
} = {},
|
||||
) {
|
||||
const sessions = input.sessions ?? [session()]
|
||||
@@ -115,7 +116,6 @@ export async function setupTimeline(
|
||||
retry: input.eventRetry ?? 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: input.protocol,
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
@@ -235,7 +235,7 @@ export function event(type: TimelinePayload["type"], properties: TimelinePayload
|
||||
}
|
||||
|
||||
export function validateTimelineEvent(input: unknown): TimelineEvent {
|
||||
return decodeEvent(input, decodeOptions)
|
||||
return decodeEvent(input, decodeOptions) as TimelineEvent
|
||||
}
|
||||
|
||||
export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
|
||||
|
||||
@@ -30,7 +30,7 @@ test("applies message latency after a list response gate is released", async ()
|
||||
})
|
||||
|
||||
const response = handler!({
|
||||
request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
|
||||
request: () => ({ url: () => "http://127.0.0.1:4096/api/session/session/message" }),
|
||||
fulfill: () => {
|
||||
events.push("fulfill")
|
||||
return Promise.resolve()
|
||||
|
||||
@@ -85,21 +85,17 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
if (url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
|
||||
@@ -181,9 +181,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
return json(route, true)
|
||||
}
|
||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
if (url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
|
||||
return json(route, { data: [] })
|
||||
if (url.pathname === "/api/model/default") return json(route, { data: null })
|
||||
@@ -211,8 +210,6 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
||||
if (current) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
@@ -222,7 +219,6 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
}
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
|
||||
@@ -58,22 +58,18 @@ async function mockServers(page: Page) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
if (url.pathname === "/api/event")
|
||||
return sse(route, url.pathname === "/api/event")
|
||||
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
|
||||
@@ -65,7 +65,6 @@ async function switchSession(page: Page, title: string) {
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v1",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
|
||||
@@ -25,7 +25,6 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
let detailFailures = 1
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v1",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -109,7 +108,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route("**/pty/pty_review_terminal*", (route) =>
|
||||
await page.route("**/api/pty/pty_review_terminal*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@@ -127,7 +126,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
|
||||
await page.route("**/api/pty/pty_review_terminal/connect-token*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@@ -137,7 +136,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
||||
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
|
||||
@@ -173,7 +173,6 @@ async function mockServer(
|
||||
},
|
||||
) {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
|
||||
@@ -90,7 +90,7 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||
})
|
||||
|
||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
id: "timeline-event-7",
|
||||
})
|
||||
@@ -107,7 +107,7 @@ test("passes through non-event fetches", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
|
||||
const health = await page.evaluate(async () => {
|
||||
const response = await fetch("/global/health")
|
||||
const response = await fetch("/api/health")
|
||||
return response.json()
|
||||
})
|
||||
|
||||
|
||||
@@ -89,23 +89,19 @@ async function mockServer(page: Page) {
|
||||
if (url.origin !== server) return route.fallback()
|
||||
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||
return new Promise(() => {})
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
if (url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
||||
if (byId) return json(route, byId)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
|
||||
@@ -10,7 +10,6 @@ const title = "Hidden terminal regression"
|
||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
|
||||
@@ -66,7 +66,6 @@ async function readProbe(page: Page) {
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"./performance/unit/visual-stability.test.ts",
|
||||
"./reproduction/timeline-suspense/**/*.ts",
|
||||
"./reproduction/timeline-suspense/**/*.tsx",
|
||||
"../src/types.ts",
|
||||
"../src/pages/session/timeline/observe-element-offset.ts",
|
||||
"./regression/new-session-panel-corner.spec.ts",
|
||||
"./regression/session-timeline-context-resize.spec.ts",
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
|
||||
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
||||
|
||||
export interface MockServerConfig {
|
||||
protocol?: "v1" | "v2"
|
||||
provider: unknown | (() => unknown)
|
||||
integrationMethods?: Record<string, unknown[]>
|
||||
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
|
||||
@@ -33,21 +29,6 @@ export interface MockServerConfig {
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const cursors = new Map<string, string>()
|
||||
let nextCursor = 0
|
||||
const staticRoutes: Record<string, unknown> = {
|
||||
"/path": {
|
||||
state: config.directory,
|
||||
config: config.directory,
|
||||
worktree: config.directory,
|
||||
directory: config.directory,
|
||||
home: "C:/OpenCode",
|
||||
},
|
||||
"/project": [config.project],
|
||||
"/project/current": config.project,
|
||||
"/agent": [{ name: "build", mode: "primary" }],
|
||||
"/vcs": { branch: "main", default_branch: "main" },
|
||||
"/session": config.sessions,
|
||||
}
|
||||
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
@@ -57,61 +38,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
||||
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event" || path === "/api/event") {
|
||||
if (path === "/api/event") {
|
||||
const events = config.events?.()
|
||||
return sse(
|
||||
route,
|
||||
path === "/api/event"
|
||||
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
||||
: [
|
||||
...(path === "/global/event"
|
||||
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
|
||||
: []),
|
||||
...(events ?? []),
|
||||
],
|
||||
[{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])],
|
||||
config.eventRetry,
|
||||
)
|
||||
}
|
||||
if (path === "/global/health")
|
||||
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
|
||||
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 === "/provider")
|
||||
return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
|
||||
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
|
||||
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
|
||||
if (legacyAuth && route.request().method() === "PUT") {
|
||||
config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
|
||||
return json(route, true)
|
||||
}
|
||||
if (path === "/instance/dispose" && route.request().method() === "POST") {
|
||||
config.onInstanceDispose?.()
|
||||
return json(route, true)
|
||||
}
|
||||
if (path === "/permission")
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
if (path === "/question")
|
||||
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
||||
if (path === "/session/status")
|
||||
return json(
|
||||
route,
|
||||
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}),
|
||||
)
|
||||
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||
if (path === "/file" && config.fileList)
|
||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/file/content" && config.fileContent)
|
||||
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/find/file" && config.findFiles)
|
||||
return json(
|
||||
route,
|
||||
await config.findFiles({
|
||||
query: url.searchParams.get("query") ?? "",
|
||||
dirs: url.searchParams.get("dirs") ?? undefined,
|
||||
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
||||
}),
|
||||
)
|
||||
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||
if (path === "/api/reference")
|
||||
return json(route, {
|
||||
location: {
|
||||
@@ -135,6 +70,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
],
|
||||
})
|
||||
if (path === "/api/command") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/plugin") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/mcp/resource")
|
||||
return json(route, { location: location(config), data: { resources: [], templates: [] } })
|
||||
@@ -149,18 +85,19 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (path === "/api/project") return json(route, [config.project])
|
||||
if (path === "/api/project/current")
|
||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||
if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
|
||||
if (path === "/api/path")
|
||||
return json(route, {
|
||||
state: config.directory,
|
||||
config: config.directory,
|
||||
worktree: config.directory,
|
||||
directory: config.directory,
|
||||
home: "C:/OpenCode",
|
||||
})
|
||||
if (path === "/api/location") return json(route, location(config))
|
||||
const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
|
||||
if (projectCopy && route.request().method() === "POST") {
|
||||
const input = route.request().postDataJSON() as { directory: string; name?: string }
|
||||
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
|
||||
}
|
||||
if (projectCopy && route.request().method() === "DELETE")
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (path === "/api/permission/request")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
@@ -177,11 +114,28 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
|
||||
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
|
||||
if (path === "/api/file" && config.fileList)
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: await config.fileList(url.searchParams.get("path") ?? ""),
|
||||
})
|
||||
if (path === "/api/file/read" && config.fileContent) {
|
||||
const value = await config.fileContent(url.searchParams.get("path") ?? "")
|
||||
const content = value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
|
||||
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
|
||||
}
|
||||
if (path === "/api/file/find" && config.findFiles)
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: await config.findFiles({
|
||||
query: url.searchParams.get("query") ?? "",
|
||||
dirs: url.searchParams.get("type") ?? undefined,
|
||||
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
||||
}),
|
||||
})
|
||||
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
|
||||
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
|
||||
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path === "/api/session") {
|
||||
const directory = url.searchParams.get("directory")
|
||||
const parentID = url.searchParams.get("parentID")
|
||||
@@ -226,12 +180,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||
return json(route, true)
|
||||
}
|
||||
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
|
||||
return json(route, true)
|
||||
}
|
||||
if (
|
||||
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||
route.request().method() === "POST"
|
||||
@@ -241,7 +189,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
|
||||
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
|
||||
if (currentSessionMatch) {
|
||||
@@ -252,28 +199,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
||||
if (sessionMatch) {
|
||||
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
||||
return json(route, session ?? {})
|
||||
}
|
||||
|
||||
const projectMatch = path.match(/^\/project\/([^/]+)$/)
|
||||
if (projectMatch) return json(route, config.project)
|
||||
|
||||
const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||
if (messageMatch) {
|
||||
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
|
||||
const currentMessageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||
if (currentMessageMatch) {
|
||||
config.onMessage?.({ sessionID: currentMessageMatch[1]!, messageID: currentMessageMatch[2]! })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const message = config.message?.(messageMatch[1]!, messageMatch[2]!)
|
||||
const message = config.message?.(currentMessageMatch[1]!, currentMessageMatch[2]!)
|
||||
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
|
||||
return json(route, message)
|
||||
return json(route, currentMessage(message))
|
||||
}
|
||||
|
||||
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||
|
||||
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||
if (currentMessagesMatch) {
|
||||
const token = url.searchParams.get("cursor") ?? undefined
|
||||
@@ -292,23 +226,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
if (messagesMatch) {
|
||||
const token = url.searchParams.get("before") ?? undefined
|
||||
const before = token ? cursors.get(token) : undefined
|
||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||
if (!pageData.cursor) return json(route, pageData.items)
|
||||
const cursor = `cursor_${++nextCursor}`
|
||||
cursors.set(cursor, pageData.cursor)
|
||||
return json(route, pageData.items, { "x-next-cursor": cursor })
|
||||
}
|
||||
|
||||
if (url.port === targetPort && targetPort !== appPort) return json(route, {})
|
||||
return route.fallback()
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
|
||||
export type SseConnectionRecord = {
|
||||
id: number
|
||||
url: string
|
||||
path: "/global/event" | "/event" | "/api/event"
|
||||
path: "/api/event"
|
||||
headers: Record<string, string>
|
||||
openedAt: number
|
||||
endedAt?: number
|
||||
@@ -174,10 +174,7 @@ export async function installSseTransport<T>(
|
||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
if (
|
||||
url.origin !== server ||
|
||||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
|
||||
)
|
||||
if (url.origin !== server || url.pathname !== "/api/event")
|
||||
return originalFetch(request)
|
||||
|
||||
const id = ++nextConnectionID
|
||||
@@ -193,18 +190,9 @@ export async function installSseTransport<T>(
|
||||
record.controller = controller
|
||||
connections.push(record)
|
||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
||||
if (url.pathname === "/api/event")
|
||||
controller.enqueue(
|
||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||
)
|
||||
if (url.pathname === "/global/event")
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
frame({
|
||||
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
|
||||
}),
|
||||
),
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||
)
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
|
||||
@@ -238,26 +238,6 @@ function UiI18nBridge(props: ParentProps) {
|
||||
}
|
||||
|
||||
function LayoutCompatibility(props: ParentProps) {
|
||||
const global = useGlobal()
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const settings = useSettings()
|
||||
|
||||
createEffect(() => {
|
||||
if (settings.general.newLayoutDesigns()) return
|
||||
const current = server.current
|
||||
if (!current) return
|
||||
const protocol = global.ensureServerCtx(current).sdk.protocolKind()
|
||||
if (protocol !== "v2") return
|
||||
const next = global.servers.list().find((s) => {
|
||||
if (ServerConnection.key(s) === ServerConnection.key(current)) return false
|
||||
return global.ensureServerCtx(s).sdk.protocolKind() !== "v2"
|
||||
})
|
||||
if (!next) return
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(ServerConnection.key(next)))
|
||||
})
|
||||
|
||||
return <>{props.children}</>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Project } from "@/types"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
|
||||
@@ -130,26 +130,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||
}
|
||||
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
|
||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||
|
||||
if (result.key) {
|
||||
await serverSDK().client.auth.set({
|
||||
providerID: result.providerID,
|
||||
auth: {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await serverSync().updateConfig({
|
||||
provider: { [result.providerID]: result.config },
|
||||
disabled_providers: nextDisabled,
|
||||
})
|
||||
return result
|
||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>): Promise<typeof result> => {
|
||||
// TODO: Restore custom providers when V2 exposes config and arbitrary credential APIs.
|
||||
throw new Error(`Custom provider ${result.providerID} is unavailable`)
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
dialog.close()
|
||||
|
||||
@@ -146,7 +146,7 @@ export function DialogEditProjectV2(props: { project: LocalProject; server: Serv
|
||||
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
|
||||
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
|
||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -160,7 +160,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
<Button type="button" variant="ghost" size="large" onClick={model.close}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
|
||||
<Button type="submit" variant="primary" size="large" disabled={!model.supported || model.save.isPending}>
|
||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
||||
import type { TextPart as SDKTextPart } from "@/types"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import type { Path } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Path } from "@/types"
|
||||
import {
|
||||
absoluteTreePath,
|
||||
activeTreeNavigation,
|
||||
@@ -66,16 +66,21 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
let pathArea: HTMLDivElement | undefined
|
||||
let navigation = 0
|
||||
|
||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
async (): Promise<Path | undefined> => {
|
||||
if ((await sdk.protocol) !== "v1") return
|
||||
return sdk.client.path
|
||||
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
|
||||
() =>
|
||||
sdk.api.location
|
||||
.get()
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined)
|
||||
},
|
||||
.then(
|
||||
(location): Path => ({
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: location.project.directory,
|
||||
directory: location.directory,
|
||||
home: "",
|
||||
}),
|
||||
)
|
||||
.catch(() => undefined),
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
||||
import type { Path } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Path } from "@/types"
|
||||
|
||||
interface DialogSelectDirectoryProps {
|
||||
title?: string
|
||||
@@ -57,16 +57,21 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const [filter, setFilter] = createSignal("")
|
||||
let list: ListRef | undefined
|
||||
|
||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
async (): Promise<Path | undefined> => {
|
||||
if ((await sdk.protocol) !== "v1") return
|
||||
return sdk.client.path
|
||||
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
|
||||
() =>
|
||||
sdk.api.location
|
||||
.get()
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined)
|
||||
},
|
||||
.then(
|
||||
(location): Path => ({
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: location.project.directory,
|
||||
directory: location.directory,
|
||||
home: "",
|
||||
}),
|
||||
)
|
||||
.catch(() => undefined),
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { detectServerProtocol } from "@/utils/server-protocol"
|
||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
@@ -264,13 +263,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (
|
||||
!settings.general.newLayoutDesigns() &&
|
||||
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||
) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
if (options.navigateOnAdd === false) {
|
||||
@@ -315,13 +307,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (
|
||||
!settings.general.newLayoutDesigns() &&
|
||||
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||
) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (normalized === input.original.http.url) {
|
||||
server.add(conn)
|
||||
} else {
|
||||
@@ -360,9 +345,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||
|
||||
const sortedItems = createMemo(() => {
|
||||
const raw = items()
|
||||
const list = settings.general.newLayoutDesigns()
|
||||
? raw
|
||||
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
||||
const list = raw
|
||||
if (!list.length) return list
|
||||
const active = current()
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
|
||||
@@ -9,6 +9,7 @@ import { type LocalProject } from "@/context/layout"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
|
||||
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
const supported = !props.project.id || props.project.id === "global"
|
||||
const dialog = useDialog()
|
||||
const global = useGlobal()
|
||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||
@@ -71,29 +72,9 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
if ((await serverCtx().sdk.protocol) !== "v1") return
|
||||
const project = await serverCtx()
|
||||
.sdk.client.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
|
||||
// const project = await serverCtx().sdk.api.project.update({
|
||||
// projectID: props.project.id,
|
||||
// name,
|
||||
// icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||
// commands: { start },
|
||||
// })
|
||||
serverCtx().sync.set("project", (items) =>
|
||||
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
|
||||
)
|
||||
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
// TODO: Restore project edits when the V2 client exposes a project update API.
|
||||
// await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands })
|
||||
throw new Error(`Project ${props.project.id} cannot be updated`)
|
||||
}
|
||||
|
||||
serverCtx().sync.project.meta(props.project.worktree, {
|
||||
@@ -107,7 +88,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault()
|
||||
if (save.isPending) return
|
||||
if (!supported || save.isPending) return
|
||||
save.mutate()
|
||||
}
|
||||
|
||||
@@ -117,6 +98,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
folderName,
|
||||
defaultName,
|
||||
save,
|
||||
supported,
|
||||
submit,
|
||||
drop,
|
||||
dragOver,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { FileNode } from "@/types"
|
||||
|
||||
describe("buildFileTreeV2Model", () => {
|
||||
test("builds a sorted tree and flattens expanded directories", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { FileNode } from "@/types"
|
||||
|
||||
export type FileTreeV2Model = {
|
||||
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { FileNode } from "@/types"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
|
||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { FileNode } from "@/types"
|
||||
|
||||
const MAX_DEPTH = 128
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ReferenceInfo } from "@/types"
|
||||
import { createEffect, createMemo, on, Show } from "solid-js"
|
||||
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import type { Todo } from "@/types"
|
||||
import { createPromptState } from "@/context/prompt"
|
||||
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
|
||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||
|
||||
@@ -81,7 +81,7 @@ import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/place
|
||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ReferenceInfo } from "@/types"
|
||||
|
||||
export { createPromptInputHistory }
|
||||
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AgentPartInput, FilePartInput, Part, TextPartInput } from "@/types"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
|
||||
@@ -125,13 +125,6 @@ beforeAll(async () => {
|
||||
useSearchParams: () => [search, () => undefined],
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: (input: { directory: string }) => {
|
||||
createdClients.push(input.directory)
|
||||
return clientFor(input.directory)
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/ui/toast", () => ({
|
||||
Toast: { Region: () => null },
|
||||
showToast: () => 0,
|
||||
@@ -197,12 +190,8 @@ beforeAll(async () => {
|
||||
const sdk = {
|
||||
scope: "local",
|
||||
directory: "/repo/main",
|
||||
client: rootClient,
|
||||
api: rootClient.api,
|
||||
url: "http://localhost:4096",
|
||||
createClient(opts: any) {
|
||||
return clientFor(opts.directory)
|
||||
},
|
||||
}
|
||||
return () => sdk
|
||||
},
|
||||
@@ -332,7 +321,7 @@ describe("prompt submit worktree selection", () => {
|
||||
selected = "/repo/worktree-b"
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(createdClients).toEqual([])
|
||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sessionCreateInputs).toEqual([
|
||||
{
|
||||
@@ -489,9 +478,6 @@ describe("prompt submit worktree selection", () => {
|
||||
agents: [],
|
||||
})
|
||||
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
||||
expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([
|
||||
{ id: expect.stringMatching(/^prt_/), type: "text", text: "ls" },
|
||||
])
|
||||
})
|
||||
|
||||
test("submits slash commands through the current session API", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Session } from "@/types"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
@@ -15,6 +15,7 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||
import { useSync, type DirectorySync } from "@/context/sync"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
@@ -159,10 +160,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
await input.api.prompt({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
agent: input.draft.agent,
|
||||
model: input.draft.model,
|
||||
variant: input.draft.variant,
|
||||
legacyParts: requestParts,
|
||||
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||
files: requestParts.flatMap((part) => {
|
||||
if (part.type !== "file") return []
|
||||
@@ -348,13 +345,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
let client = sdk().client
|
||||
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await client.worktree
|
||||
.create({ directory: projectDirectory })
|
||||
.then((x) => x.data)
|
||||
const createdWorktree = await sdk()
|
||||
.api.projectCopy.create({
|
||||
projectID: sync().data.project,
|
||||
strategy: "git_worktree",
|
||||
directory: getDirectory(projectDirectory),
|
||||
location: { directory: projectDirectory },
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
@@ -362,14 +361,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!createdWorktree?.directory) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: language.t("common.requestFailed"),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!createdWorktree) return
|
||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
@@ -379,10 +371,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
client = sdk().createClient({
|
||||
directory: sessionDirectory,
|
||||
throwOnError: true,
|
||||
})
|
||||
serverSync().child(sessionDirectory)
|
||||
}
|
||||
|
||||
@@ -487,8 +475,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
agent,
|
||||
model,
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part } from "@/types"
|
||||
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
|
||||
|
||||
const user = (id: string) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part } from "@/types"
|
||||
|
||||
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message } from "@/types"
|
||||
import { getSessionContext } from "./session-context-metrics"
|
||||
|
||||
const assistant = (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AssistantMessage, Message } from "@/types"
|
||||
|
||||
type Provider = {
|
||||
id: string
|
||||
|
||||
@@ -10,7 +10,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part, UserMessage } from "@/types"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
|
||||
@@ -128,11 +128,7 @@ export const SettingsGeneral: Component = () => {
|
||||
|
||||
const [shells] = createResource(
|
||||
async () => {
|
||||
const sdk = serverSdk()
|
||||
if ((await sdk.protocol) === "v1") {
|
||||
return (await sdk.client.pty.shells()).data ?? []
|
||||
}
|
||||
// return (await sdk.api.pty.shells()).data
|
||||
// TODO: Restore executable shell discovery; V2 shell.list only lists shell processes.
|
||||
return [] as ShellOption[]
|
||||
},
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
@@ -331,6 +327,7 @@ export const SettingsGeneral: Component = () => {
|
||||
>
|
||||
<Select
|
||||
data-action="settings-shell"
|
||||
disabled
|
||||
options={shellOptions()}
|
||||
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
||||
value={(o) => o.id}
|
||||
@@ -338,7 +335,8 @@ export const SettingsGeneral: Component = () => {
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
if (option.value === currentShell()) return
|
||||
serverSync().updateConfig({ shell: option.value })
|
||||
// TODO: Restore config writes when the V2 client exposes a config API.
|
||||
// void serverSync().updateConfig({ shell: option.value })
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
@@ -39,7 +39,6 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const protocol = useServerProtocol()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders(() => undefined)
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
@@ -84,8 +83,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
const canDisconnect = (item: ProviderItem) =>
|
||||
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
|
||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
@@ -98,7 +96,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
if (protocol() !== "v1") return
|
||||
return
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
@@ -121,17 +119,14 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await serverSDK()
|
||||
.client.auth.remove({ providerID })
|
||||
.catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
return
|
||||
}
|
||||
await serverSDK()
|
||||
.client.auth.remove({ providerID })
|
||||
.then(async () => {
|
||||
await serverSDK().client.global.dispose()
|
||||
.api.integration.get({ integrationID: providerID })
|
||||
.then(async (integration) => {
|
||||
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
||||
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
||||
await Promise.all(
|
||||
credentials.map((credential) => serverSDK().api.credential.remove({ credentialID: credential.id })),
|
||||
)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
@@ -221,7 +216,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={protocol() === "v1"}>
|
||||
<Show when={false}>
|
||||
<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"
|
||||
data-component="custom-provider-section"
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useUpdaterAction } from "../updater-action"
|
||||
import {
|
||||
monoDefault,
|
||||
@@ -91,7 +90,6 @@ export const SettingsGeneralV2: Component<{
|
||||
const dialog = useDialog()
|
||||
const settings = useSettings()
|
||||
const serverSync = useServerSync()
|
||||
const serverSdk = useServerSDK()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
|
||||
const updater = useUpdaterAction()
|
||||
@@ -123,10 +121,7 @@ export const SettingsGeneralV2: Component<{
|
||||
|
||||
const [shells] = createResource(
|
||||
async () => {
|
||||
const sdk = serverSdk()
|
||||
if ((await sdk.protocol) === "v1") {
|
||||
return (await sdk.client.pty.shells()).data ?? []
|
||||
}
|
||||
// TODO: Restore executable shell discovery when the V2 client exposes it.
|
||||
// return (await sdk.api.pty.shells()).data
|
||||
return [] as ShellOption[]
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||
@@ -36,7 +36,6 @@ export const SettingsProvidersV2: Component<{
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const protocol = useServerProtocol()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders(props.directory)
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
@@ -81,8 +80,7 @@ export const SettingsProvidersV2: Component<{
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
const canDisconnect = (item: ProviderItem) =>
|
||||
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
|
||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
@@ -95,7 +93,7 @@ export const SettingsProvidersV2: Component<{
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
if (protocol() !== "v1") return
|
||||
return
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
@@ -118,17 +116,17 @@ export const SettingsProvidersV2: Component<{
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await serverSdk()
|
||||
.client.auth.remove({ providerID })
|
||||
.catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
return
|
||||
}
|
||||
const location = props.directory() ? { directory: props.directory() } : undefined
|
||||
await serverSdk()
|
||||
.client.auth.remove({ providerID })
|
||||
.then(async () => {
|
||||
await serverSdk().client.global.dispose()
|
||||
.api.integration.get({ integrationID: providerID, location })
|
||||
.then(async (integration) => {
|
||||
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
||||
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
||||
await Promise.all(
|
||||
credentials.map((credential) =>
|
||||
serverSdk().api.credential.remove({ credentialID: credential.id, location }),
|
||||
),
|
||||
)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
@@ -224,7 +222,7 @@ export const SettingsProvidersV2: Component<{
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={protocol() === "v1"}>
|
||||
<Show when={false}>
|
||||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||
<div class="settings-v2-provider-lead">
|
||||
<ProviderIcon
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { type Accessor, createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -16,7 +16,7 @@ import { type ServerHealth } from "@/utils/server-health"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { useServerProtocol } from "@/context/server-sdk"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -251,6 +251,7 @@ function ServerStatusList(props: { state: ServerStatusState }) {
|
||||
|
||||
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
@@ -258,7 +259,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const settings = useSettings()
|
||||
const protocol = useServerProtocol()
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
showToast({
|
||||
@@ -279,9 +279,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
dialogRun += 1
|
||||
})
|
||||
const sortedServers = createMemo(() => {
|
||||
const list = settings.general.newLayoutDesigns()
|
||||
? global.servers.list()
|
||||
: global.servers.list().filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
||||
const list = global.servers.list()
|
||||
return listServersByHealth(list, server.key, global.servers.health)
|
||||
})
|
||||
const toggleMcp = useMcpToggle()
|
||||
@@ -291,9 +289,11 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const plugins = createMemo(() =>
|
||||
(sync().data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
|
||||
const [pluginList] = createResource(
|
||||
() => (props.shown() ? sdk().directory : undefined),
|
||||
(directory) => sdk().api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
@@ -322,7 +322,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||
{language.t("status.popover.tab.lsp")}
|
||||
</Tabs.Trigger>
|
||||
<Show when={protocol() === "v1"}>
|
||||
<Show when={true}>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||
{language.t("status.popover.tab.plugins")}
|
||||
@@ -487,7 +487,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Show when={protocol() === "v1"}>
|
||||
<Show when={true}>
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { LspStatus } from "@/types"
|
||||
import type { McpServer } from "@opencode-ai/client/promise"
|
||||
|
||||
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { matchKeybind, parseKeybind } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { terminalFontFamily, useSettings } from "@/context/settings"
|
||||
import type { LocalPTY } from "@/context/terminal"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
@@ -175,15 +174,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
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 url = sdk().url
|
||||
const auth = connection.http
|
||||
const username = auth?.username ?? "opencode"
|
||||
const password = auth?.password ?? ""
|
||||
const authToken = connection.type === "http" ? connection.authToken : false
|
||||
const sameOrigin = new URL(url, location.href).origin === location.origin
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, [
|
||||
"pty",
|
||||
@@ -241,16 +233,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const pushSize = async (cols: number, rows: number) => {
|
||||
if ((await sdk().protocol) === "v1") {
|
||||
return sdk()
|
||||
.client.pty.update({
|
||||
ptyID: id,
|
||||
size: { cols, rows },
|
||||
})
|
||||
.catch((err) => {
|
||||
debugTerminal("failed to sync terminal size", err)
|
||||
})
|
||||
}
|
||||
return sdk()
|
||||
.api.pty.update({
|
||||
ptyID: id,
|
||||
@@ -533,15 +515,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const gone = async () => {
|
||||
if ((await sdk().protocol) === "v1") {
|
||||
return sdk()
|
||||
.client.pty.get({ ptyID: id }, { throwOnError: false })
|
||||
.then((result) => result.response.status === 404)
|
||||
.catch((err) => {
|
||||
debugTerminal("failed to inspect terminal session", err)
|
||||
return false
|
||||
})
|
||||
}
|
||||
return sdk()
|
||||
.api.pty.get({ ptyID: id, location: { directory } })
|
||||
.then((result) => result.data.status === "exited")
|
||||
@@ -553,33 +526,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const connectToken = async () => {
|
||||
if ((await sdk().protocol) === "v1") {
|
||||
const result = await sdk()
|
||||
.client.pty.connectToken(
|
||||
{ ptyID: id, directory },
|
||||
{
|
||||
throwOnError: false,
|
||||
headers: { "x-opencode-ticket": "1" },
|
||||
},
|
||||
)
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error && err.message.includes("Request is not supported")) return
|
||||
throw err
|
||||
})
|
||||
if (!result) return
|
||||
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
|
||||
if (result.response.status === 404 || result.response.status === 405) return
|
||||
if (result.response.status === 403)
|
||||
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
||||
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
|
||||
}
|
||||
// return sdk()
|
||||
// .api.pty.connectToken({
|
||||
// ptyID: id,
|
||||
// location: { directory },
|
||||
// "x-opencode-ticket": "1",
|
||||
// })
|
||||
// .then((result) => result.data.ticket)
|
||||
// TODO: Add PTY tickets when the V2 client exposes a connect-token API.
|
||||
return undefined
|
||||
}
|
||||
|
||||
const retry = (err: unknown) => {
|
||||
@@ -609,23 +557,16 @@ export const Terminal = (props: TerminalProps) => {
|
||||
fail(err)
|
||||
return undefined
|
||||
})
|
||||
const protocol = await sdk().protocol
|
||||
// if (protocol === "v2" && !ticket) return
|
||||
if (once.value) return
|
||||
if (disposed) return
|
||||
|
||||
const socket = new WebSocket(
|
||||
terminalWebSocketURL({
|
||||
protocol,
|
||||
url,
|
||||
id,
|
||||
directory,
|
||||
cursor: seek,
|
||||
ticket,
|
||||
sameOrigin,
|
||||
username,
|
||||
password,
|
||||
authToken,
|
||||
}),
|
||||
)
|
||||
socket.binaryType = "arraybuffer"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import type { Session } from "@/types"
|
||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
||||
import "./titlebar-tab-nav.css"
|
||||
|
||||
@@ -19,7 +19,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import type { Session } from "@/types"
|
||||
|
||||
function SessionTabSlot(props: {
|
||||
tab: SessionTab
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part, Session } from "@/types"
|
||||
import { createMemo } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||
import type { createServerSdkContext } from "./server-sdk"
|
||||
@@ -26,7 +26,6 @@ export const createDirSyncContext = (
|
||||
serverSync: ReturnType<typeof createServerSyncContextInner>,
|
||||
serverSDK: ReturnType<typeof createServerSdkContext>,
|
||||
) => {
|
||||
const client = serverSDK.createClient({ directory, throwOnError: true })
|
||||
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const data = new Proxy({} as State, {
|
||||
@@ -134,15 +133,8 @@ export const createDirSyncContext = (
|
||||
},
|
||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||
archive: async (sessionID: string) => {
|
||||
if ((await serverSDK.protocol) !== "v1") return
|
||||
await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } })
|
||||
current()[1](
|
||||
"session",
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft, sessionID, (session) => session.id)
|
||||
if (match.found) draft.splice(match.index, 1)
|
||||
}),
|
||||
)
|
||||
// TODO: Restore archiving when the V2 client exposes a session archive API.
|
||||
void sessionID
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
|
||||
@@ -81,8 +81,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
normalizeDir: path.normalizeDir,
|
||||
list: (dir) =>
|
||||
sdk()
|
||||
.client.file.list({ path: dir })
|
||||
.then((x) => x.data ?? []),
|
||||
.api.file.list({ path: dir, location: { directory: scope() } })
|
||||
.then((x) =>
|
||||
x.data.map((entry) => ({
|
||||
...entry,
|
||||
name: entry.path.split("/").at(-1) ?? entry.path,
|
||||
absolute: `${scope()}/${entry.path}`,
|
||||
ignored: false,
|
||||
})),
|
||||
),
|
||||
onError: (message) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
@@ -181,10 +188,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
setLoading(file)
|
||||
|
||||
const promise = sdk()
|
||||
.client.file.read({ path: file })
|
||||
.then((x) => {
|
||||
.api.file.read({ path: file, location: { directory } })
|
||||
.then((data) => {
|
||||
if (scope() !== directory) return
|
||||
const content = x.data
|
||||
const content = { type: "text" as const, content: new TextDecoder().decode(data) }
|
||||
setLoaded(file, content)
|
||||
|
||||
if (!content) return
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
import type { FileContent } from "@/types"
|
||||
|
||||
const MAX_FILE_CONTENT_ENTRIES = 40
|
||||
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { FileNode } from "@/types"
|
||||
|
||||
type DirectoryState = {
|
||||
expanded: boolean
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
import type { FileContent } from "@/types"
|
||||
|
||||
export type FileSelection = {
|
||||
startLine: number
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import type { FileNode } from "@/types"
|
||||
|
||||
type WatcherEvent = {
|
||||
type: string
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import {
|
||||
bootstrapDirectory,
|
||||
loadAgentsQuery,
|
||||
loadCommands,
|
||||
loadPathQuery,
|
||||
@@ -13,139 +10,19 @@ import {
|
||||
loadProvidersQuery,
|
||||
loadReferencesQuery,
|
||||
} from "./bootstrap"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
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 legacy MCP endpoints while refreshing a v1 directory", async () => {
|
||||
const mcpReads: string[] = []
|
||||
const [store, setStore] = directoryState()
|
||||
|
||||
await bootstrapDirectory({
|
||||
directory: "/project",
|
||||
scope: ServerScope.local,
|
||||
mcp: true,
|
||||
global: {
|
||||
config: {} satisfies Config,
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
project: [{ id: "project", worktree: "/project" } as Project],
|
||||
provider,
|
||||
},
|
||||
sdk: {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: { status: async () => ({ data: {} }) },
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: {
|
||||
list: async () => {
|
||||
mcpReads.push("command")
|
||||
return { data: [] }
|
||||
},
|
||||
},
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
||||
mcp: {
|
||||
status: async () => {
|
||||
mcpReads.push("status")
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
resource: {
|
||||
list: async () => {
|
||||
mcpReads.push("resource")
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient,
|
||||
api,
|
||||
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", () => {
|
||||
test("partitions identical directories by server scope", () => {
|
||||
const client = {} as Parameters<typeof loadPathQuery>[2]
|
||||
const api = {} as CatalogApi
|
||||
const location = {} as ServerApi["location"]
|
||||
const remote = "https://debian.example" as typeof ServerScope.local
|
||||
|
||||
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
|
||||
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["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"])
|
||||
})
|
||||
|
||||
@@ -180,6 +57,21 @@ describe("query keys", () => {
|
||||
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 () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
Config,
|
||||
OpencodeClient,
|
||||
Path,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
@@ -8,7 +7,7 @@ import type {
|
||||
QuestionRequest,
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
} from "@/types"
|
||||
import type {
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
@@ -16,6 +15,8 @@ import type {
|
||||
CommandInfo,
|
||||
CommandListInput,
|
||||
CommandListOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
ProjectListOutput,
|
||||
@@ -43,7 +44,6 @@ import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import type { ServerProtocol } from "@/utils/server-protocol"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
type GlobalStore = {
|
||||
@@ -105,16 +105,18 @@ function showErrors(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||
export const loadGlobalConfigQuery = (scope: ServerScope) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "config"],
|
||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
||||
// TODO: Restore config loading when the V2 client exposes a config API.
|
||||
queryFn: async (): Promise<Config> => ({}),
|
||||
})
|
||||
|
||||
type ProjectApi = {
|
||||
readonly list: () => Promise<ProjectListOutput>
|
||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||
}
|
||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||
|
||||
type McpApi = ServerApi["mcp"]
|
||||
type PermissionApi = ServerApi["permission"]
|
||||
@@ -138,9 +140,7 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||
})
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
serverSDK: OpencodeClient
|
||||
serverAPI: CatalogApi & { readonly project: ProjectApi }
|
||||
protocol?: Promise<ServerProtocol>
|
||||
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
|
||||
scope: ServerScope
|
||||
requestFailedTitle: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
@@ -149,12 +149,12 @@ export async function bootstrapGlobal(input: {
|
||||
queryClient: QueryClient
|
||||
}) {
|
||||
const slow = [
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
||||
loadProvidersQuery(input.scope, null, input.serverAPI),
|
||||
),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||
@@ -219,17 +219,11 @@ export const loadProvidersQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string | null,
|
||||
sdk: CatalogApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "providers"],
|
||||
queryFn: () =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) {
|
||||
const result = await legacy.provider.list()
|
||||
return normalizeProviderList(result.data!)
|
||||
}
|
||||
const location = directory ? { location: { directory } } : undefined
|
||||
const [providers, models, defaultModel] = await Promise.all([
|
||||
sdk.provider.list(location),
|
||||
@@ -256,71 +250,45 @@ export const loadAgentsQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
sdk: AgentListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "agents"],
|
||||
queryFn: () =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? [])
|
||||
return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))
|
||||
}),
|
||||
retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
|
||||
})
|
||||
|
||||
export const loadCommands = (
|
||||
directory: string,
|
||||
api: CommandListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
): Promise<CommandInfo[]> =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) {
|
||||
return ((await legacy.command.list()).data ?? []).map((command) => {
|
||||
const [providerID, id] = command.model?.split("/") ?? []
|
||||
return {
|
||||
name: command.name,
|
||||
template: command.template,
|
||||
description: command.description,
|
||||
agent: command.agent,
|
||||
model: providerID && id ? { providerID, id } : undefined,
|
||||
subtask: command.subtask,
|
||||
// source: command.source === "skill" ? undefined : command.source,
|
||||
}
|
||||
})
|
||||
}
|
||||
return api.list({ location: { directory } }).then((result) => result.data)
|
||||
})
|
||||
retry(() => api.list({ location: { directory } }).then((result) => result.data))
|
||||
|
||||
export const loadPathQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string | null,
|
||||
sdk: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
api: LocationApi,
|
||||
) =>
|
||||
queryOptions<Path>({
|
||||
queryKey: [scope, directory, "path"],
|
||||
queryFn: async () => {
|
||||
if ((await protocol) !== "v1")
|
||||
return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" }
|
||||
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
|
||||
},
|
||||
queryFn: () =>
|
||||
api.get(directory ? { location: { directory } } : undefined).then((location) => ({
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: location.project.directory,
|
||||
directory: location.directory,
|
||||
home: "",
|
||||
})),
|
||||
})
|
||||
|
||||
export const loadReferencesQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: ReferenceListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
queryOptions<ReferenceInfo[]>({
|
||||
queryKey: [scope, directory, "references"] as const,
|
||||
queryFn: () =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? []
|
||||
return api.list({ location: { directory } }).then((result) => result.data)
|
||||
}).catch(() => []),
|
||||
retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []),
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
@@ -328,7 +296,6 @@ export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
scope: ServerScope
|
||||
mcp: boolean
|
||||
sdk: OpencodeClient
|
||||
api: CatalogApi & {
|
||||
readonly agent: AgentListApi
|
||||
readonly command: CommandListApi
|
||||
@@ -339,6 +306,7 @@ export async function bootstrapDirectory(input: {
|
||||
readonly reference: ReferenceListApi
|
||||
readonly session: SessionApi
|
||||
readonly vcs: VcsApi
|
||||
readonly location: LocationApi
|
||||
}
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
@@ -353,7 +321,6 @@ export async function bootstrapDirectory(input: {
|
||||
}
|
||||
queryClient: QueryClient
|
||||
session?: ServerSession
|
||||
protocol?: Promise<ServerProtocol>
|
||||
}) {
|
||||
const loading = input.store.status !== "complete"
|
||||
const seededProject = projectID(input.directory, input.global.project)
|
||||
@@ -373,37 +340,8 @@ export async function bootstrapDirectory(input: {
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
|
||||
.then((data) => input.setStore("agent", data)),
|
||||
() =>
|
||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
||||
() =>
|
||||
retry(() =>
|
||||
(async () => {
|
||||
if ((await input.protocol) !== "v1") return
|
||||
const x = await input.sdk.session.status()
|
||||
if (!input.session) {
|
||||
input.setStore("session_status", x.data!)
|
||||
return
|
||||
}
|
||||
const statuses = x.data ?? {}
|
||||
input.session.set(
|
||||
"session_status",
|
||||
produce((draft) => {
|
||||
for (const sessionID of Object.keys(draft)) {
|
||||
if (statuses[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
||||
input.session.set("session_status", sessionID, reconcile(status))
|
||||
}
|
||||
await Promise.all(
|
||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
||||
)
|
||||
})(),
|
||||
),
|
||||
!seededProject &&
|
||||
(() =>
|
||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||
@@ -412,37 +350,26 @@ export async function bootstrapDirectory(input: {
|
||||
!seededPath &&
|
||||
(() =>
|
||||
input.queryClient
|
||||
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol))
|
||||
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location))
|
||||
.then((data) => {
|
||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
})),
|
||||
() =>
|
||||
retry(async () => {
|
||||
if ((await input.protocol) !== "v1") return
|
||||
return input.sdk.vcs.get().then((result) => {
|
||||
const next = { branch: result.data?.branch, default_branch: result.data?.default_branch }
|
||||
input.setStore("vcs", next)
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
})
|
||||
}),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) =>
|
||||
loadCommands(input.directory, input.api.command).then((commands) =>
|
||||
input.setStore("command", commands),
|
||||
)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol),
|
||||
loadReferencesQuery(input.scope, input.directory, input.api.reference),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
(async () => {
|
||||
if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
|
||||
return input.api.permission.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data.map(normalizePermissionRequest))
|
||||
})().then((permissions) => {
|
||||
input.api.permission.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data.map(normalizePermissionRequest))
|
||||
.then((permissions) => {
|
||||
const ids = permissions.map((permission) => permission.sessionID)
|
||||
const grouped = groupBySession(
|
||||
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||
@@ -473,12 +400,10 @@ export async function bootstrapDirectory(input: {
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
(async () => {
|
||||
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
|
||||
return input.api.question.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
})().then((questions) => {
|
||||
input.api.question.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
.then((questions) => {
|
||||
const ids = questions.map((question) => question.sessionID)
|
||||
const grouped = groupBySession(
|
||||
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
||||
@@ -511,16 +436,16 @@ export async function bootstrapDirectory(input: {
|
||||
input.mcp &&
|
||||
(() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
|
||||
loadMcpQuery(input.scope, input.directory, input.api.mcp),
|
||||
)),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
|
||||
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp),
|
||||
)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
|
||||
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api))
|
||||
.catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { VcsInfo } from "@/types"
|
||||
import {
|
||||
DIR_IDLE_TTL_MS,
|
||||
MAX_DIR_STORES,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@/types"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { State } from "./types"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
Session,
|
||||
SessionStatus,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
} from "@/types"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionV2Info } from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionV2Info } from "@/types"
|
||||
import {
|
||||
applyHomeSessionEvent,
|
||||
appendHomeSessionEvent,
|
||||
@@ -33,7 +33,7 @@ describe("Home V2 session index", () => {
|
||||
const calls: unknown[] = []
|
||||
const result = await loadHomeSessionIndex(async (input) => {
|
||||
calls.push(input)
|
||||
return { data: { data: [session({ id: "root" })], cursor: {} } }
|
||||
return { data: [session({ id: "root" })], cursor: {} }
|
||||
})
|
||||
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
@@ -48,15 +48,13 @@ describe("Home V2 session index", () => {
|
||||
calls.push({ input, signal: options.signal })
|
||||
if (!("cursor" in input)) {
|
||||
return {
|
||||
data: {
|
||||
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
|
||||
session({ id: `page-1-${index}` }),
|
||||
),
|
||||
cursor: { next: "next-page" },
|
||||
},
|
||||
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
|
||||
session({ id: `page-1-${index}` }),
|
||||
),
|
||||
cursor: { next: "next-page" },
|
||||
}
|
||||
}
|
||||
return { data: { data: [session({ id: "page-2" })], cursor: {} } }
|
||||
return { data: [session({ id: "page-2" })], cursor: {} }
|
||||
},
|
||||
0,
|
||||
controller.signal,
|
||||
@@ -145,10 +143,8 @@ describe("Home V2 session index", () => {
|
||||
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
|
||||
})
|
||||
|
||||
test("refetches after reconnect, disposal, and session moves", () => {
|
||||
test("refetches after reconnect", () => {
|
||||
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
|
||||
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
|
||||
expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
|
||||
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@/types"
|
||||
import type { QueryClient } from "@tanstack/solid-query"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
@@ -21,13 +21,11 @@ export type HomeSessionIndex = {
|
||||
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
|
||||
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
|
||||
|
||||
type HomeSessionPage = { data?: V2SessionListResponse }
|
||||
|
||||
export async function loadHomeSessionIndex(
|
||||
list: (
|
||||
input: { limit: number; order: "desc"; cursor?: string },
|
||||
options: { signal?: AbortSignal },
|
||||
) => Promise<HomeSessionPage>,
|
||||
) => Promise<V2SessionListResponse>,
|
||||
eventSequence = 0,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
@@ -43,7 +41,7 @@ export async function loadHomeSessionIndex(
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
const page = response.data!
|
||||
const page = response
|
||||
data.push(...page.data)
|
||||
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
|
||||
return { sessions: parseHomeSessionIndex(data), eventSequence }
|
||||
@@ -75,10 +73,7 @@ export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, ev
|
||||
|
||||
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
|
||||
if (event === "server.connected") return { connected: true, refetch: connected }
|
||||
return {
|
||||
connected,
|
||||
refetch: event === "global.disposed" || event === "session.next.moved",
|
||||
}
|
||||
return { connected, refetch: false }
|
||||
}
|
||||
|
||||
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
|
||||
@@ -145,7 +140,7 @@ export function retainHomeSessions(sessions: Session[], limit: number, now: numb
|
||||
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
|
||||
const info = event.properties.info
|
||||
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
|
||||
return sessions.toSpliced(index, 1)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/promise"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
|
||||
const result = await input.api.list({
|
||||
@@ -16,16 +15,6 @@ export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; d
|
||||
} as const
|
||||
}
|
||||
|
||||
export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
|
||||
try {
|
||||
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
|
||||
return { data: result.data, limit: input.limit, limited: true } as const
|
||||
} catch {
|
||||
const result = await input.client.session.list({ directory: input.directory, roots: true })
|
||||
return { data: result.data, limit: input.limit, limited: false } as const
|
||||
}
|
||||
}
|
||||
|
||||
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
|
||||
if (!input.limited) return input.count
|
||||
if (input.count < input.limit) return input.count
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { PermissionRequest, Session } from "@/types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
|
||||
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { PermissionRequest, Session } from "@/types"
|
||||
import { cmp } from "./utils"
|
||||
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
SessionStatus,
|
||||
Todo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
} from "@/types"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
PermissionRequest,
|
||||
ProviderListOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { Agent, Event, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Agent, Event, Project, Provider, ProviderListResponse } from "@/types"
|
||||
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
||||
@@ -58,20 +58,7 @@ export function normalizeProviderList(
|
||||
defaultModel?: ModelDefaultOutput["data"],
|
||||
): NormalizedProviderListResponse {
|
||||
if (!Array.isArray(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"),
|
||||
),
|
||||
},
|
||||
]),
|
||||
),
|
||||
}
|
||||
return providers
|
||||
}
|
||||
const all = new Map<string, Provider>()
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useServerSync } from "./server-sync"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
||||
import { usePlatform } from "./platform"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import type { Project } from "@/types"
|
||||
import { normalizeProjectInfo } from "./global-sync/utils"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
@@ -572,17 +572,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
|
||||
const projectID = project.id
|
||||
void (async () => {
|
||||
const sdk = serverSdk()
|
||||
if ((await sdk.protocol) !== "v1") return
|
||||
return sdk.client.project
|
||||
.update({ projectID, directory: worktree, icon: { color } })
|
||||
.then((response) => response.data)
|
||||
.then((result) => {
|
||||
if (!result) return
|
||||
serverSync().set("project", (items) =>
|
||||
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
|
||||
)
|
||||
})
|
||||
// TODO: Restore project color updates when the V2 client exposes a project update API.
|
||||
void projectID
|
||||
})().catch(() => {
|
||||
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSessionError } from "@/types"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSoundById } from "@/utils/sound"
|
||||
import { useGlobal } from "./global"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { PermissionRequest, Session } from "@/types"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
import type { PermissionRequest } from "@/types"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { ServerSDK } from "@/context/server-sdk"
|
||||
import type { ServerSync } from "./server-sync"
|
||||
@@ -212,7 +212,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||
)
|
||||
|
||||
function enableConfiguredDirectory(directory: string) {
|
||||
if (input.sdk.protocolKind() !== "v1") return
|
||||
if (meta.disposed || !ready()) return
|
||||
const [childStore] = input.sync.child(directory)
|
||||
if (childStore.config.permission !== "allow") return
|
||||
@@ -250,7 +249,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||
sessionID: request.sessionID,
|
||||
requestID: request.permissionID,
|
||||
reply: request.response,
|
||||
location: request.directory ? { directory: request.directory } : undefined,
|
||||
})
|
||||
.catch(() => {
|
||||
responded.delete(request.permissionID)
|
||||
@@ -258,9 +256,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||
}
|
||||
|
||||
const list = async (directory: string) => {
|
||||
if ((await input.sdk.protocol) === "v1") {
|
||||
return (await input.sdk.client.permission.list({ directory })).data ?? []
|
||||
}
|
||||
return input.sdk.api.permission.request
|
||||
.list({ location: { directory } })
|
||||
.then((result) => result.data.map(normalizePermissionRequest))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
|
||||
import type { FilePartSource } from "@/types"
|
||||
import { batch, createMemo, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { type ServerSDK, useServerSDK } from "./server-sdk"
|
||||
import { type DirectorySDK, useServerSDK } from "./server-sdk"
|
||||
export type { DirectorySDK } from "./server-sdk"
|
||||
|
||||
export type DirectorySDK = ReturnType<ServerSDK["ensureDirSdkContext"]>
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
const context = createSimpleContext({
|
||||
name: "SDK",
|
||||
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
|
||||
init: (props: { directory: string | Accessor<string> }) => {
|
||||
@@ -15,3 +14,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const useSDK: () => Accessor<DirectorySDK> = context.use
|
||||
export const SDKProvider = context.provider
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
test("restarts a stream only after a back-forward cache restore", () => {
|
||||
@@ -16,7 +15,7 @@ describe("resumeStreamAfterPageShow", () => {
|
||||
})
|
||||
|
||||
describe("adaptServerEvent", () => {
|
||||
test("preserves V2 events while adapting permission requests for existing consumers", () => {
|
||||
test("preserves current events while adapting permission requests for existing consumers", () => {
|
||||
const current = {
|
||||
id: "evt_1",
|
||||
created: 1,
|
||||
@@ -25,6 +24,7 @@ describe("adaptServerEvent", () => {
|
||||
} as OpenCodeEvent
|
||||
|
||||
expect(adaptServerEvent(current)).toMatchObject({
|
||||
id: "evt_1",
|
||||
type: "permission.asked",
|
||||
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
|
||||
current,
|
||||
@@ -32,165 +32,40 @@ describe("adaptServerEvent", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("coalesceServerEvents", () => {
|
||||
const delta = (value: string, field = "text", partID = "part") => ({
|
||||
describe("current event buffering", () => {
|
||||
const delta = (id: string, value: string, ordinal = 0) => ({
|
||||
directory: "/repo",
|
||||
payload: {
|
||||
type: "message.part.delta",
|
||||
properties: { messageID: "msg", partID, field, delta: value },
|
||||
} as Event,
|
||||
payload: adaptServerEvent({
|
||||
id,
|
||||
created: 1,
|
||||
type: "session.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
||||
} as OpenCodeEvent),
|
||||
})
|
||||
|
||||
test("merges adjacent deltas for the same field", () => {
|
||||
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") },
|
||||
])
|
||||
test("merges adjacent text deltas for the same message and ordinal", () => {
|
||||
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
|
||||
})
|
||||
|
||||
test("preserves event boundaries and distinct fields", () => {
|
||||
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")])
|
||||
test("preserves boundaries between distinct delta streams", () => {
|
||||
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
||||
|
||||
expect(result.map((event) => event.payload.type)).toEqual([
|
||||
"message.part.delta",
|
||||
"message.part.delta",
|
||||
"session.status",
|
||||
"message.part.delta",
|
||||
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual([
|
||||
"evt_1",
|
||||
"evt_2",
|
||||
"evt_3",
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves event ID order across interleaved deltas", () => {
|
||||
const first = delta("a")
|
||||
const other = delta("b", "text", "other")
|
||||
const last = delta("c")
|
||||
first.payload.id = "1"
|
||||
other.payload.id = "2"
|
||||
last.payload.id = "3"
|
||||
test("preserves current event order when enqueuing", () => {
|
||||
const events: Parameters<typeof enqueueServerEvent>[0] = []
|
||||
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
|
||||
|
||||
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)
|
||||
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Event, PermissionRequest } from "@/types"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { type Accessor, batch, createMemo, createResource, onCleanup, onMount } from "solid-js"
|
||||
import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server"
|
||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createApiForServer, type ServerApi } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { createRefCountMap } from "@/utils/refcount"
|
||||
import { useGlobal } from "./global"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
|
||||
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
|
||||
|
||||
const isAbortError = (error: unknown) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
|
||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||
export type ServerEvent = Event & { current?: OpenCodeEvent }
|
||||
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
||||
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
||||
type CurrentDelta = Extract<
|
||||
OpenCodeEvent,
|
||||
@@ -41,29 +39,14 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
event.data.source?.type === "tool"
|
||||
? { messageID: event.data.source.messageID, callID: event.data.source.callID }
|
||||
: undefined,
|
||||
},
|
||||
} satisfies PermissionRequest,
|
||||
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) {
|
||||
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)
|
||||
return true
|
||||
}
|
||||
@@ -99,33 +82,7 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
output.push(event)
|
||||
return
|
||||
}
|
||||
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 },
|
||||
},
|
||||
}
|
||||
output.push(event)
|
||||
})
|
||||
return output
|
||||
}
|
||||
@@ -160,20 +117,13 @@ type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]:
|
||||
type ServerSDKBase = {
|
||||
server: ServerConnection.Any
|
||||
scope: ServerScope
|
||||
protocol: Promise<ServerProtocol>
|
||||
protocolKind: Accessor<ServerProtocol | undefined>
|
||||
url: string
|
||||
client: ReturnType<typeof createSdkForServer>
|
||||
api: CompatibleApi
|
||||
currentApi: ServerApi
|
||||
api: ServerApi
|
||||
event: {
|
||||
on: ServerEventEmitter["on"]
|
||||
listen: ServerEventEmitter["listen"]
|
||||
start: () => Promise<void> | undefined
|
||||
}
|
||||
createClient: (
|
||||
opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">,
|
||||
) => ReturnType<typeof createSdkForServer>
|
||||
}
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
@@ -192,16 +142,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
})()
|
||||
|
||||
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
|
||||
const eventSdk = createSdkForServer({
|
||||
signal: abort.signal,
|
||||
fetch: eventFetch,
|
||||
server: server.http,
|
||||
})
|
||||
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
|
||||
const [protocolKind] = createResource(
|
||||
() => protocol,
|
||||
(value) => value,
|
||||
)
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: ServerEvent
|
||||
}>()
|
||||
@@ -264,18 +204,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
}
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
try {
|
||||
const kind = await protocol
|
||||
const events =
|
||||
kind === "v1"
|
||||
? (await eventSdk.global.event({ signal: attempt.signal })).stream
|
||||
: eventApi.event.subscribe({ signal: attempt.signal })
|
||||
const events = eventApi.event.subscribe({ signal: attempt.signal })
|
||||
let yielded = Date.now()
|
||||
for await (const event of events) {
|
||||
streamErrorLogged = false
|
||||
const legacy = "payload" in event
|
||||
if (legacy && event.payload.type === "sync") continue
|
||||
const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global")
|
||||
const payload = legacy ? (event.payload as Event) : adaptServerEvent(event)
|
||||
const directory = event.location?.directory ?? "global"
|
||||
const payload = adaptServerEvent(event)
|
||||
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
@@ -325,42 +259,18 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
flush()
|
||||
})
|
||||
|
||||
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 api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
|
||||
return {
|
||||
server,
|
||||
scope,
|
||||
protocol,
|
||||
protocolKind,
|
||||
url: server.http.url,
|
||||
client: sdk,
|
||||
api,
|
||||
currentApi,
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
listen: emitter.listen.bind(emitter),
|
||||
start,
|
||||
},
|
||||
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
||||
return createSdkForServer({
|
||||
server: server.http,
|
||||
fetch: platform.fetch,
|
||||
...opts,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,21 +302,19 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
||||
},
|
||||
})
|
||||
|
||||
export function useServerProtocol() {
|
||||
const serverSDK = useServerSDK()
|
||||
return createMemo(() => serverSDK().protocolKind())
|
||||
}
|
||||
|
||||
type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
||||
}
|
||||
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||
const client = serverSDK.createClient({
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
export type DirectorySDK = {
|
||||
scope: ServerScope
|
||||
directory: string
|
||||
api: ServerApi
|
||||
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
|
||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||
|
||||
const unsub = serverSDK.event.on(directory, (event) => {
|
||||
@@ -416,21 +324,11 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||
|
||||
return {
|
||||
scope: serverSDK.scope,
|
||||
protocol: serverSDK.protocol,
|
||||
directory,
|
||||
client,
|
||||
api: createCompatibleApi({
|
||||
protocol: serverSDK.protocol,
|
||||
current: serverSDK.currentApi,
|
||||
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
||||
directory,
|
||||
}),
|
||||
api: serverSDK.api,
|
||||
event: emitter,
|
||||
get url() {
|
||||
return serverSDK.url
|
||||
},
|
||||
createClient(opts: Parameters<typeof serverSDK.createClient>[0]) {
|
||||
return serverSDK.createClient(opts)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise"
|
||||
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
OpenCodeEvent,
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { Message, Part, Session } from "@/types"
|
||||
import { createServerSession } from "./server-session"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
@@ -21,19 +28,130 @@ const session = (id: string, parentID?: string): Session => ({
|
||||
type UserMessage = Extract<Message, { role: "user" }>
|
||||
type AssistantMessage = Extract<Message, { role: "assistant" }>
|
||||
type TextPart = Extract<Part, { type: "text" }>
|
||||
type CurrentToolObject = Extract<SessionMessageAssistantTool["state"], { status: "running" }>["input"]
|
||||
type MessageResponse = {
|
||||
data: { info: Message; parts: Part[] }[]
|
||||
response: { headers: Headers }
|
||||
}
|
||||
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 => ({
|
||||
id,
|
||||
sessionID: "child",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
model: { providerID: "provider", modelID: "model", variant: undefined },
|
||||
...input,
|
||||
})
|
||||
|
||||
@@ -45,21 +163,24 @@ const assistantMessage = (id: string, parentID: string, input: Partial<Assistant
|
||||
parentID,
|
||||
modelID: "model",
|
||||
providerID: "provider",
|
||||
variant: undefined,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: "/repo", root: "/repo" },
|
||||
path: { cwd: "", root: "" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
error: undefined,
|
||||
finish: undefined,
|
||||
...input,
|
||||
})
|
||||
|
||||
const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
|
||||
id: "part",
|
||||
sessionID: "child",
|
||||
messageID,
|
||||
type: "text",
|
||||
text: "text",
|
||||
...input,
|
||||
id: `${messageID}:text:${input.id === "pending" ? 1 : 0}`,
|
||||
})
|
||||
|
||||
const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({
|
||||
@@ -73,19 +194,27 @@ const deferredResponse = () => Promise.withResolvers<MessageResponse>()
|
||||
|
||||
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
|
||||
let index = 0
|
||||
const pages = responses.map((value) =>
|
||||
value instanceof Promise ? value.then(currentPage) : Promise.resolve(currentPage(value)),
|
||||
)
|
||||
const requests: unknown[] = []
|
||||
const waiting = new Map<number, () => void>()
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: session("child", "root") }),
|
||||
messages: (input: unknown) => {
|
||||
get: async () => sessionInfo(session("child", "root")),
|
||||
message: async () => {
|
||||
throw new Error("Unexpected single message request")
|
||||
},
|
||||
},
|
||||
message: {
|
||||
list: async (input: unknown) => {
|
||||
requests.push(input)
|
||||
waiting.get(requests.length)?.()
|
||||
waiting.delete(requests.length)
|
||||
return responses[index++]
|
||||
return pages[index++]!
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
} as unknown as { session: SessionApi; message: MessageApi }
|
||||
return Object.assign(client, {
|
||||
requests,
|
||||
requested(count: number) {
|
||||
@@ -106,19 +235,22 @@ function rootMessageClient(
|
||||
const rootWaiting = new Map<number, () => void>()
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: session("child", "root") }),
|
||||
messages: (input: unknown) => {
|
||||
requests.push(input)
|
||||
return pages[pageIndex++]
|
||||
},
|
||||
message: (input: unknown) => {
|
||||
get: async () => sessionInfo(session("child", "root")),
|
||||
message: async (input: unknown) => {
|
||||
rootRequests.push(input)
|
||||
rootWaiting.get(rootRequests.length)?.()
|
||||
rootWaiting.delete(rootRequests.length)
|
||||
return roots[rootIndex++]
|
||||
const value = await roots[rootIndex++]!
|
||||
return currentMessages([value.data]).find((message) => message.id === value.data.info.id)!
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
message: {
|
||||
list: async (input: unknown) => {
|
||||
requests.push(input)
|
||||
return currentPage(await pages[pageIndex++]!)
|
||||
},
|
||||
},
|
||||
} as unknown as { session: SessionApi; message: MessageApi }
|
||||
return Object.assign(client, {
|
||||
requests,
|
||||
rootRequests,
|
||||
@@ -148,16 +280,19 @@ function setup(sessions: Record<string, Session>) {
|
||||
get: async (input: unknown) => {
|
||||
get.push(input)
|
||||
const id = (input as { sessionID: string }).sessionID
|
||||
return { data: sessions[id] }
|
||||
return sessionInfo(sessions[id]!)
|
||||
},
|
||||
messages: async (input: unknown) => {
|
||||
messages.push(input)
|
||||
return response()
|
||||
message: async () => {
|
||||
throw new Error("Unexpected single message request")
|
||||
},
|
||||
diff: async () => ({ data: [] }),
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
message: {
|
||||
list: async (input: unknown) => {
|
||||
messages.push(input)
|
||||
return currentPage(response())
|
||||
},
|
||||
},
|
||||
} as unknown as { session: SessionApi; message: MessageApi }
|
||||
return { get, messages, store: createServerSession(client) }
|
||||
}
|
||||
|
||||
@@ -229,7 +364,7 @@ describe("server session", () => {
|
||||
await ctx.store.sync("root")
|
||||
|
||||
expect(ctx.get).toEqual([{ sessionID: "root" }])
|
||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }])
|
||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
||||
expect(ctx.store.data.message.root).toEqual([])
|
||||
})
|
||||
|
||||
@@ -244,20 +379,13 @@ describe("server session", () => {
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
}
|
||||
const client = {
|
||||
session: {
|
||||
messages: () => {
|
||||
throw new Error("legacy message endpoint called")
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const messageApi = {
|
||||
list: async (input: unknown) => {
|
||||
requests.push(input)
|
||||
return { data: [assistant, user], cursor: { previous: null, next: null } }
|
||||
},
|
||||
} as unknown as MessageApi
|
||||
const store = createServerSession(client, {} as SessionApi, messageApi)
|
||||
const store = createServerSession({} as SessionApi, messageApi)
|
||||
store.remember(session("root"))
|
||||
|
||||
await store.sync("root")
|
||||
@@ -292,7 +420,7 @@ describe("server session", () => {
|
||||
return pages.shift()!
|
||||
},
|
||||
} as unknown as MessageApi
|
||||
const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi)
|
||||
const store = createServerSession({} as SessionApi, messageApi)
|
||||
store.remember(session("root"))
|
||||
|
||||
await store.sync("root")
|
||||
@@ -308,50 +436,14 @@ describe("server session", () => {
|
||||
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
|
||||
})
|
||||
|
||||
test("indexes V1 messages for the current timeline projection", async () => {
|
||||
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])
|
||||
})
|
||||
|
||||
// V2 messages are ordered projections and do not expose V1 assistant parent IDs.
|
||||
describe.skip("V1 assistant parent projections", () => {
|
||||
test("backfills an assistant-only initial page through its user root", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response(
|
||||
assistants.map((info) => ({ info, parts: [] })),
|
||||
"older",
|
||||
),
|
||||
response(assistants.map((info) => ({ info, parts: [] }))),
|
||||
],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
@@ -359,16 +451,16 @@ describe("server session", () => {
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }])
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }])
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
expect(store.history.more("child")).toBe(true)
|
||||
expect(store.history.more("child")).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
|
||||
const missing = Promise.withResolvers<SingleMessageResponse>()
|
||||
const assistant = assistantMessage("message-2", "message-missing")
|
||||
const client = rootMessageClient([response([{ info: assistant, parts: [] }], "older")], [missing.promise])
|
||||
const client = rootMessageClient([response([{ info: assistant, parts: [] }])], [missing.promise])
|
||||
const store = createServerSession(client)
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
@@ -378,7 +470,7 @@ describe("server session", () => {
|
||||
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
|
||||
expect(store.data.message.child).toEqual([assistant])
|
||||
expect(store.history.more("child")).toBe(true)
|
||||
expect(store.history.more("child")).toBe(false)
|
||||
})
|
||||
|
||||
test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
|
||||
@@ -392,7 +484,7 @@ describe("server session", () => {
|
||||
{ info: parent, parts: [part] },
|
||||
{ info: assistant, parts: [] },
|
||||
]),
|
||||
response([{ info: assistant, parts: [] }], "older"),
|
||||
response([{ info: assistant, parts: [] }]),
|
||||
],
|
||||
[missing.promise],
|
||||
)
|
||||
@@ -414,10 +506,7 @@ describe("server session", () => {
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response(
|
||||
assistants.map((info) => ({ info, parts: [] })),
|
||||
"older",
|
||||
),
|
||||
response(assistants.map((info) => ({ info, parts: [] }))),
|
||||
],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
@@ -439,10 +528,7 @@ describe("server session", () => {
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response([{ info: unrelated, parts: [] }]),
|
||||
response(
|
||||
assistants.map((info) => ({ info, parts: [] })),
|
||||
"older",
|
||||
),
|
||||
response(assistants.map((info) => ({ info, parts: [] }))),
|
||||
],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
@@ -461,7 +547,7 @@ describe("server session", () => {
|
||||
const cached = userMessage("message-3", { time: { created: 3 } })
|
||||
const assistant = assistantMessage("message-4", user.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }], "older")],
|
||||
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }])],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
@@ -479,7 +565,7 @@ describe("server session", () => {
|
||||
const freshPart = { ...stalePart, text: "fresh" }
|
||||
const assistant = assistantMessage("message-2", stale.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }], "older")],
|
||||
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }])],
|
||||
[singleResponse(fresh, [freshPart])],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
@@ -500,7 +586,7 @@ describe("server session", () => {
|
||||
const pending = textPart(stale.id, { id: "pending", text: "pending" })
|
||||
const assistant = assistantMessage("message-2", stale.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }], "older")],
|
||||
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])],
|
||||
[singleResponse(fresh, [refreshed])],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
@@ -523,7 +609,7 @@ describe("server session", () => {
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: user } })
|
||||
pending.resolve(response([{ info: assistant, parts: [] }], "older"))
|
||||
pending.resolve(response([{ info: assistant, parts: [] }]))
|
||||
await loading
|
||||
|
||||
expect(client.rootRequests).toEqual([])
|
||||
@@ -539,7 +625,6 @@ describe("server session", () => {
|
||||
[
|
||||
response(
|
||||
assistants.map((info) => ({ info, parts: [] })),
|
||||
"older",
|
||||
),
|
||||
],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
@@ -563,7 +648,7 @@ describe("server session", () => {
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const live = { ...assistant, cost: 1 }
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [] }], "older")],
|
||||
[response([{ info: assistant, parts: [] }])],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
@@ -583,7 +668,7 @@ describe("server session", () => {
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const live = userMessage("message-4", { time: { created: 4 } })
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [] }], "older")],
|
||||
[response([{ info: assistant, parts: [] }])],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
@@ -604,7 +689,7 @@ describe("server session", () => {
|
||||
const stale = textPart(assistant.id, { text: "stale" })
|
||||
const live = { ...stale, text: "live" }
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [stale] }], "older")],
|
||||
[response([{ info: assistant, parts: [stale] }])],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
@@ -617,6 +702,7 @@ describe("server session", () => {
|
||||
|
||||
expect(store.data.part[assistant.id]).toEqual([live])
|
||||
})
|
||||
})
|
||||
|
||||
test("merges live events into the initial page", async () => {
|
||||
const pending = deferredResponse()
|
||||
@@ -1416,6 +1502,7 @@ describe("server session", () => {
|
||||
|
||||
await store.history.loadMore("child")
|
||||
|
||||
guard.active = false
|
||||
expect(store.data.message.child).toEqual([older, latest])
|
||||
})
|
||||
|
||||
|
||||
@@ -3,19 +3,16 @@ import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
Message,
|
||||
OpencodeClient,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
} from "@/types"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { batch } from "solid-js"
|
||||
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 { normalizeSessionInfo } from "@/utils/session"
|
||||
import { normalizeSessionMessages } from "@/utils/session-message"
|
||||
@@ -33,6 +30,29 @@ const historyMessagePageSize = 200
|
||||
const sessionInfoLimit = 2_048
|
||||
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[]) {
|
||||
const boundary = source.find(
|
||||
(message) =>
|
||||
@@ -61,30 +81,6 @@ type MessagePage = {
|
||||
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.
|
||||
type MessageLoadState = {
|
||||
touchedMessages: Set<string>
|
||||
@@ -183,16 +179,18 @@ function reconcileFetched<T extends { id: string }>(
|
||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
|
||||
type ServerSessionOptions = { retry?: typeof retry }
|
||||
type ServerSessionApis = { session: SessionApi; message: MessageApi }
|
||||
|
||||
export function createServerSession(
|
||||
client: OpencodeClient,
|
||||
sessionApiOrOptions?: SessionApi | ServerSessionOptions,
|
||||
messageApi?: MessageApi,
|
||||
api: SessionApi | ServerSessionApis,
|
||||
messageApiOrOptions?: MessageApi | ServerSessionOptions,
|
||||
currentOptions?: ServerSessionOptions,
|
||||
) {
|
||||
const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined
|
||||
const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined)
|
||||
const bundled = "session" in api
|
||||
const sessionApi = bundled ? api.session : api
|
||||
const messageApi = bundled ? api.message : (messageApiOrOptions as MessageApi)
|
||||
const options = bundled ? (messageApiOrOptions as ServerSessionOptions | undefined) : currentOptions
|
||||
const [data, setData] = createStore({
|
||||
info: {} as Record<string, Session | undefined>,
|
||||
session_status: {} as Record<string, SessionStatus>,
|
||||
@@ -247,13 +245,13 @@ export function createServerSession(
|
||||
at: {} as Record<string, number | undefined>,
|
||||
})
|
||||
|
||||
const indexLegacyMessage = (message: Message) => {
|
||||
const indexProjectedMessage = (message: Message) => {
|
||||
const current = data.session_message[message.sessionID] ?? []
|
||||
if (current.some((item) => item.id === message.id)) return
|
||||
setData(
|
||||
"session_message",
|
||||
message.sessionID,
|
||||
reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]),
|
||||
reconcile([...current, ...projectMessageSource(message)]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -307,12 +305,7 @@ export function createServerSession(
|
||||
const pending = requests.get(sessionID)
|
||||
if (pending) return pending
|
||||
const active = generation(sessionID)
|
||||
const request = sessionApi
|
||||
? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
|
||||
: client.session.get({ sessionID }).then((result) => {
|
||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
||||
return result.data
|
||||
})
|
||||
const request = sessionApi.get({ sessionID }).then(normalizeSessionInfo)
|
||||
const resolved = request.then((result) => {
|
||||
if (generations.get(sessionID) !== active) return result
|
||||
return remember(result)
|
||||
@@ -538,72 +531,43 @@ export function createServerSession(
|
||||
)
|
||||
|
||||
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||
if (messageApi && (await options?.protocol) !== "v1") {
|
||||
const request = (cursor?: string) =>
|
||||
(options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
|
||||
})
|
||||
const first = await request(before)
|
||||
const pages = [first]
|
||||
while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) {
|
||||
const response = await request(pages.at(-1)!.cursor.next ?? undefined)
|
||||
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.data.length === 0,
|
||||
}
|
||||
const request = (cursor?: string) =>
|
||||
(options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
|
||||
})
|
||||
const first = await request(before)
|
||||
const pages = [first]
|
||||
while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) {
|
||||
const response = await request(pages.at(-1)!.cursor.next ?? undefined)
|
||||
pages.push(response)
|
||||
if (!response.data.length) break
|
||||
}
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return client.session.messages({ sessionID, limit, before })
|
||||
})
|
||||
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
||||
const response = pages.at(-1)!
|
||||
const source = pages.flatMap((page) => page.data).toReversed()
|
||||
const normalized = normalizeSessionMessages(sessionID, source)
|
||||
return {
|
||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
||||
part: items.map((item) => ({
|
||||
id: item.info.id,
|
||||
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
})),
|
||||
source: legacyMessageSource(items),
|
||||
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),
|
||||
cursor: response.response.headers.get("x-next-cursor") ?? undefined,
|
||||
complete: !response.response.headers.get("x-next-cursor"),
|
||||
projectSource: true,
|
||||
cursor: response.cursor.next ?? undefined,
|
||||
complete: !response.cursor.next,
|
||||
}
|
||||
}
|
||||
|
||||
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)(() => {
|
||||
onAttempt?.()
|
||||
return client.session.message({ sessionID, messageID })
|
||||
return sessionApi.message({ sessionID, messageID })
|
||||
})
|
||||
if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`)
|
||||
return {
|
||||
message: cleanMessage(response.data.info),
|
||||
parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
}
|
||||
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 replaceMessages = (sessionID: string, messages: Message[]) => {
|
||||
@@ -683,7 +647,14 @@ export function createServerSession(
|
||||
? (() => {
|
||||
const incoming = new Map(page.source.map((message) => [message.id, message]))
|
||||
const existing = data.session_message[sessionID] ?? []
|
||||
const current = existing.filter((message) => !incoming.has(message.id))
|
||||
const boundary = Math.min(...page.source.map((message) => message.time.created))
|
||||
const current = existing.filter(
|
||||
(message) =>
|
||||
!incoming.has(message.id) &&
|
||||
(page.sourceMode === "older" ||
|
||||
load?.touchedSource.has(message.id) ||
|
||||
(!page.complete && message.time.created < boundary)),
|
||||
)
|
||||
const live = new Map(existing.map((message) => [message.id, message]))
|
||||
return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
|
||||
(message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),
|
||||
@@ -1029,8 +1000,8 @@ export function createServerSession(
|
||||
return
|
||||
}
|
||||
case "message.updated": {
|
||||
const info = cleanMessage((event.properties as { info: Message }).info)
|
||||
indexLegacyMessage(info)
|
||||
const info = (event.properties as { info: Message }).info
|
||||
indexProjectedMessage(info)
|
||||
const load = messageLoads.get(info.sessionID)
|
||||
load?.touchedMessages.add(info.id)
|
||||
load?.removedMessages.delete(info.id)
|
||||
@@ -1381,17 +1352,8 @@ export function createServerSession(
|
||||
async todo(sessionID: string, request?: { force?: boolean }) {
|
||||
touch(sessionID)
|
||||
if (data.todo[sessionID] !== undefined && !request?.force) return
|
||||
if ((await options?.protocol) === "v2") {
|
||||
setData("todo", sessionID, [])
|
||||
return
|
||||
}
|
||||
return runInflight(inflightTodo, sessionID, () => {
|
||||
const active = generation(sessionID)
|
||||
return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => {
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
||||
})
|
||||
})
|
||||
// TODO: Restore todos when the V2 client exposes a session todo API.
|
||||
setData("todo", sessionID, [])
|
||||
},
|
||||
history: {
|
||||
more: (sessionID: string) =>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
McpListInput,
|
||||
McpResourceCatalogInput,
|
||||
@@ -84,7 +83,7 @@ describe("active session query", () => {
|
||||
})
|
||||
|
||||
test("does not overwrite statuses already written by events", () => {
|
||||
const session = createServerSession({} as OpencodeClient)
|
||||
const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"])
|
||||
session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 })
|
||||
|
||||
seedActiveSessionStatuses(session, {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type {
|
||||
Config,
|
||||
OpencodeClient,
|
||||
Path,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
SessionStatus,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
} from "@/types"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
@@ -27,7 +26,7 @@ import {
|
||||
} from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load"
|
||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||
@@ -59,6 +58,7 @@ import type {
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createServerSession, type ServerSession } from "./server-session"
|
||||
import { usePlatform } from "./platform"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -94,8 +94,6 @@ export const loadMcpQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: McpListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<"v1" | "v2">,
|
||||
): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> =>
|
||||
queryOptions<
|
||||
Record<string, McpServer["status"]>,
|
||||
@@ -105,7 +103,6 @@ export const loadMcpQuery = (
|
||||
>({
|
||||
queryKey: [scope, directory, "mcp"] as const,
|
||||
queryFn: async () => {
|
||||
if ((await protocol) === "v1" && legacy) return (await legacy.mcp.status()).data ?? {}
|
||||
return api
|
||||
.list({ location: { directory } })
|
||||
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
||||
@@ -116,8 +113,6 @@ export const loadMcpResourcesQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: McpResourceApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<"v1" | "v2">,
|
||||
): ApiQueryOptions<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> =>
|
||||
queryOptions<
|
||||
Record<string, McpResource>,
|
||||
@@ -127,14 +122,6 @@ export const loadMcpResourcesQuery = (
|
||||
>({
|
||||
queryKey: [scope, directory, "mcpResources"] as const,
|
||||
queryFn: async () => {
|
||||
if ((await protocol) === "v1" && legacy) {
|
||||
return Object.fromEntries(
|
||||
Object.entries((await legacy.experimental.resource.list()).data ?? {}).map(([key, resource]) => [
|
||||
key,
|
||||
{ ...resource, server: resource.client },
|
||||
]),
|
||||
)
|
||||
}
|
||||
return api.resource
|
||||
.catalog({ location: { directory } })
|
||||
.then((result) =>
|
||||
@@ -144,10 +131,11 @@ export const loadMcpResourcesQuery = (
|
||||
placeholderData: {},
|
||||
})
|
||||
|
||||
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
export const loadLspQuery = (scope: ServerScope, directory: string) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "lsp"] as const,
|
||||
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
||||
// TODO: Restore LSP status when the V2 client exposes an LSP API.
|
||||
queryFn: async () => [],
|
||||
})
|
||||
|
||||
export const loadActiveSessionsQuery = (
|
||||
@@ -178,25 +166,19 @@ export function seedActiveSessionStatuses(
|
||||
|
||||
function makeQueryOptionsApi(
|
||||
scope: ServerScope,
|
||||
serverSDK: () => OpencodeClient,
|
||||
serverAPI: ServerApi,
|
||||
sdkFor: (dir: PathKey) => OpencodeClient,
|
||||
protocol: Promise<"v1" | "v2">,
|
||||
) {
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
||||
globalConfig: () => loadGlobalConfigQuery(scope),
|
||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||
providers: (directory: PathKey | null) =>
|
||||
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
|
||||
path: (directory: PathKey | null) =>
|
||||
loadPathQuery(scope, directory, directory ? sdkFor(directory) : serverSDK(), protocol),
|
||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol),
|
||||
references: (directory: PathKey) =>
|
||||
loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol),
|
||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
|
||||
mcpResources: (directory: PathKey) =>
|
||||
loadMcpResourcesQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
|
||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
|
||||
loadProvidersQuery(scope, directory, serverAPI),
|
||||
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
|
||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
|
||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
|
||||
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
|
||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
|
||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||
}
|
||||
}
|
||||
@@ -204,36 +186,16 @@ export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
||||
|
||||
export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const owner = getOwner()
|
||||
if (!owner) throw new Error("ServerSync must be created within owner")
|
||||
|
||||
const sdkCache = new Map<string, OpencodeClient>()
|
||||
const booting = new Map<string, Promise<void>>()
|
||||
const sessionLoads = new Map<string, Promise<void>>()
|
||||
const sessionMeta = new Map<string, { limit: number }>()
|
||||
|
||||
const sdkFor = (directory: string) => {
|
||||
const key = directoryKey(directory)
|
||||
const cached = sdkCache.get(key)
|
||||
if (cached) return cached
|
||||
const sdk = serverSDK.createClient({
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
sdkCache.set(key, sdk)
|
||||
return sdk
|
||||
}
|
||||
|
||||
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
|
||||
protocol: serverSDK.protocol,
|
||||
})
|
||||
const queryOptionsApi = makeQueryOptionsApi(
|
||||
serverSDK.scope,
|
||||
() => serverSDK.client,
|
||||
serverSDK.api,
|
||||
sdkFor,
|
||||
serverSDK.protocol,
|
||||
)
|
||||
const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
|
||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
|
||||
|
||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
||||
@@ -241,18 +203,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const activeSessionsQuery = useQuery(() =>
|
||||
loadActiveSessionsQuery(serverSDK.scope, {
|
||||
active: async () => {
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
const statuses = (await serverSDK.client.session.status()).data ?? {}
|
||||
seedActiveSessionStatuses(session, statuses)
|
||||
for (const sessionID of Object.keys(statuses)) {
|
||||
void session.resolve(sessionID).catch(() => undefined)
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(statuses).flatMap(([sessionID, status]) =>
|
||||
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
|
||||
),
|
||||
)
|
||||
}
|
||||
const active = await serverSDK.api.session.active()
|
||||
seedActiveSessionStatuses(session, active)
|
||||
for (const sessionID of Object.keys(active)) {
|
||||
@@ -321,9 +271,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
queryKey: [serverSDK.scope, "bootstrap"],
|
||||
queryFn: async () => {
|
||||
await bootstrapGlobal({
|
||||
serverSDK: serverSDK.client,
|
||||
serverAPI: serverSDK.api,
|
||||
protocol: serverSDK.protocol,
|
||||
scope: serverSDK.scope,
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
translate: language.t,
|
||||
@@ -363,7 +311,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
void bootstrapInstance(directory)
|
||||
},
|
||||
onMcp: (directory, setStore) => {
|
||||
void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol)
|
||||
void loadCommands(directory, serverSDK.api.command)
|
||||
.then((commands) => setStore("command", commands))
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
@@ -377,7 +325,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const key = directoryKey(directory)
|
||||
queue.clear(key)
|
||||
sessionMeta.delete(key)
|
||||
sdkCache.delete(key)
|
||||
clearProviderRev(serverSDK.scope, key)
|
||||
},
|
||||
translate: language.t,
|
||||
@@ -416,12 +363,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
.fetchQuery({
|
||||
...queryOptionsApi.sessions(key),
|
||||
queryFn: () =>
|
||||
serverSDK.protocol
|
||||
.then((protocol) =>
|
||||
protocol === "v1"
|
||||
? loadRootSessionsV1({ client: sdkFor(directory), directory, limit })
|
||||
: loadRootSessions({ api: serverSDK.api.session, directory, limit }),
|
||||
)
|
||||
loadRootSessions({ api: serverSDK.api.session, directory, limit })
|
||||
.then((x) => {
|
||||
const nonArchived = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
@@ -479,7 +421,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const child = children.ensureChild(directory)
|
||||
const cache = children.vcsCache.get(key)
|
||||
if (!cache) return
|
||||
const sdk = sdkFor(directory)
|
||||
await bootstrapDirectory({
|
||||
directory,
|
||||
scope: serverSDK.scope,
|
||||
@@ -490,7 +431,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
project: globalStore.project,
|
||||
provider: globalStore.provider,
|
||||
},
|
||||
sdk,
|
||||
api: serverSDK.api,
|
||||
store: child[0],
|
||||
setStore: child[1],
|
||||
@@ -499,7 +439,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
translate: language.t,
|
||||
queryClient,
|
||||
session,
|
||||
protocol: serverSDK.protocol,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -538,7 +477,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
if (event.current) session.applyV2(event.current)
|
||||
session.apply(event)
|
||||
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
||||
homeSessions.apply(event)
|
||||
if ("info" in event.properties) homeSessions.apply(event as Parameters<typeof homeSessions.apply>[0])
|
||||
}
|
||||
homeSessions.refresh(event.type)
|
||||
if (eventType === "integration.connection.updated") void refreshProviders()
|
||||
@@ -658,7 +597,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
}
|
||||
|
||||
const updateConfigMutation = useMutation(() => ({
|
||||
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
|
||||
mutationFn: async (config: Config) => {
|
||||
// TODO: Restore config updates when the V2 client exposes a config API.
|
||||
// await serverSDK.api.config.update({ config })
|
||||
throw new Error(`Config updates are unavailable: ${Object.keys(config).length} fields were not saved`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
bootstrap.refetch()
|
||||
// Invalidate all provider queries so newly configured custom providers
|
||||
@@ -692,27 +635,35 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
const key = directoryKey(directory)
|
||||
const sdk = sdkFor(key)
|
||||
const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status
|
||||
if (!status) return
|
||||
await toggleMcp({
|
||||
status,
|
||||
connect: async () => {
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
await sdk.mcp.connect({ name })
|
||||
return
|
||||
}
|
||||
await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
|
||||
},
|
||||
disconnect: async () => {
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
await sdk.mcp.disconnect({ name })
|
||||
return
|
||||
}
|
||||
await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
|
||||
},
|
||||
authenticate: async () => {
|
||||
await sdk.mcp.auth.authenticate({ name })
|
||||
const server = (await serverSDK.api.mcp.list({ location: { directory: key } })).data.find(
|
||||
(item) => item.name === name,
|
||||
)
|
||||
if (!server?.integrationID) throw new Error(`MCP server ${name} has no authentication integration`)
|
||||
const integration = await serverSDK.api.integration.get({
|
||||
integrationID: server.integrationID,
|
||||
location: { directory: key },
|
||||
})
|
||||
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.prompts?.length)
|
||||
if (!method || method.type !== "oauth")
|
||||
throw new Error(`MCP server ${name} requires an interactive authentication form`)
|
||||
const attempt = await serverSDK.api.integration.oauth.connect({
|
||||
integrationID: server.integrationID,
|
||||
methodID: method.id,
|
||||
inputs: {},
|
||||
location: { directory: key },
|
||||
})
|
||||
platform.openLink(attempt.data.url)
|
||||
},
|
||||
refresh: async () => {
|
||||
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part } from "@/types"
|
||||
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
|
||||
|
||||
type Text = Extract<Part, { type: "text" }>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part } from "@/types"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Session } from "@/types"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
||||
|
||||
@@ -248,20 +248,12 @@ function createWorkspaceTerminalSession(
|
||||
setStore("all", index, (item) => ({ ...item, ...pty }))
|
||||
}
|
||||
const doUpdate = async () => {
|
||||
if ((await sdk.protocol) === "v1") {
|
||||
await sdk.client.pty.update({
|
||||
ptyID: pty.id,
|
||||
title: pty.title,
|
||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||
})
|
||||
} else {
|
||||
await sdk.api.pty.update({
|
||||
ptyID: pty.id,
|
||||
location,
|
||||
title: pty.title,
|
||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||
})
|
||||
}
|
||||
await sdk.api.pty.update({
|
||||
ptyID: pty.id,
|
||||
location,
|
||||
title: pty.title,
|
||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||
})
|
||||
}
|
||||
doUpdate().catch((error: unknown) => {
|
||||
if (previous) {
|
||||
@@ -276,17 +268,7 @@ function createWorkspaceTerminalSession(
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
if (!pty) return
|
||||
const data = await (async () => {
|
||||
if ((await sdk.protocol) === "v1") {
|
||||
return (await sdk.client.pty.create({ title: pty.title })).data
|
||||
}
|
||||
return (
|
||||
await sdk.api.pty.create({
|
||||
location,
|
||||
title: pty.title,
|
||||
})
|
||||
).data
|
||||
})().catch((error: unknown) => {
|
||||
const data = await sdk.api.pty.create({ location, title: pty.title }).then((result) => result.data).catch((error: unknown) => {
|
||||
console.error("Failed to clone terminal", error)
|
||||
return undefined
|
||||
})
|
||||
@@ -326,10 +308,7 @@ function createWorkspaceTerminalSession(
|
||||
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
|
||||
|
||||
const doCreate = async () => {
|
||||
if ((await sdk.protocol) === "v1") {
|
||||
return (await sdk.client.pty.create({ title: defaultTitle(nextNumber) })).data
|
||||
}
|
||||
return (await sdk.api.pty.create({ location, title: defaultTitle(nextNumber) })).data
|
||||
return sdk.api.pty.create({ location, title: defaultTitle(nextNumber) }).then((result) => result.data)
|
||||
}
|
||||
doCreate()
|
||||
.then((data) => {
|
||||
@@ -433,11 +412,7 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
}
|
||||
|
||||
const removePromise =
|
||||
(await sdk.protocol) === "v1"
|
||||
? sdk.client.pty.remove({ ptyID: id })
|
||||
: sdk.api.pty.remove({ ptyID: id, location })
|
||||
await removePromise.catch((error: unknown) => {
|
||||
await sdk.api.pty.remove({ ptyID: id, location }).catch((error: unknown) => {
|
||||
console.error("Failed to close terminal", error)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Session } from "@/types"
|
||||
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMarked } from "@opencode-ai/ui/context/marked"
|
||||
@@ -69,7 +69,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const cache = homeSessions()
|
||||
const eventSequence = cache.eventSequence()
|
||||
const index = await loadHomeSessionIndex(
|
||||
(input, options) => ctx.sdk.client.v2.session.list(input, options),
|
||||
(input, options) => ctx.sdk.api.session.list(input, options),
|
||||
eventSequence,
|
||||
signal,
|
||||
)
|
||||
@@ -211,16 +211,11 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!conn || !ctx) return
|
||||
const [, setStore] = ctx.sync.child(session.directory)
|
||||
if ((await ctx.sdk.protocol) !== "v1") return
|
||||
await archiveHomeSession({
|
||||
server: ServerConnection.key(conn),
|
||||
session,
|
||||
archive: (sessionID) =>
|
||||
ctx.sdk.client.session.update({
|
||||
sessionID,
|
||||
directory: session.directory,
|
||||
time: { archived: Date.now() },
|
||||
}),
|
||||
// TODO: Restore archiving when the V2 client exposes a session archive API.
|
||||
archive: async (_sessionID) => Promise.reject(new Error("Session archiving is unavailable")),
|
||||
remove: () =>
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Session } from "@/types"
|
||||
import { type Accessor, createMemo, For, Show } from "solid-js"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
|
||||
@@ -25,8 +25,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { Session } from "@/types"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
@@ -390,22 +390,6 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
}
|
||||
|
||||
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 (
|
||||
e.details?.type === "question.replied" ||
|
||||
e.details?.type === "question.rejected" ||
|
||||
@@ -872,17 +856,14 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
}
|
||||
|
||||
async function archiveSession(session: Session) {
|
||||
if ((await serverSDK().protocol) !== "v1") return
|
||||
// TODO: Restore archiving when the V2 client exposes a session archive API.
|
||||
void session
|
||||
return
|
||||
const [store, setStore] = serverSync().child(session.directory)
|
||||
const sessions = store.session ?? []
|
||||
const index = sessions.findIndex((s) => s.id === session.id)
|
||||
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
||||
|
||||
await serverSDK().client.session.update({
|
||||
sessionID: session.id,
|
||||
directory: session.directory,
|
||||
time: { archived: Date.now() },
|
||||
})
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, session.id, (s) => s.id)
|
||||
@@ -980,7 +961,8 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
title: language.t("command.session.archive"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: "mod+shift+backspace",
|
||||
disabled: !params.dir || !params.id,
|
||||
// TODO: Restore the command when the V2 client exposes session archive.
|
||||
disabled: true,
|
||||
onSelect: () => {
|
||||
const session = currentSessions().find((s) => s.id === params.id)
|
||||
if (session) void archiveSession(session)
|
||||
@@ -1301,16 +1283,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
const name = next === getFilename(project.worktree) ? "" : next
|
||||
|
||||
if (project.id && project.id !== "global") {
|
||||
const sdk = serverSDK()
|
||||
if ((await sdk.protocol) !== "v1") return
|
||||
const result = await sdk.client.project
|
||||
.update({ projectID: project.id, directory: project.worktree, name })
|
||||
.then((response) => response.data)
|
||||
if (!result) return
|
||||
// const result = await serverSDK().api.project.update({ projectID: project.id, name })
|
||||
serverSync().set("project", (items) =>
|
||||
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
|
||||
)
|
||||
// TODO: Restore project renames when the V2 client exposes a project update API.
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1403,16 +1376,19 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
|
||||
setBusy(directory, true)
|
||||
|
||||
const result = await serverSDK()
|
||||
.client.worktree.remove({ directory: root, worktreeRemoveInput: { directory } })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: errorMessage(err, language.t("common.requestFailed")),
|
||||
})
|
||||
return false
|
||||
})
|
||||
const projectID = serverSync().data.project.find((project) => project.worktree === root)?.id
|
||||
const result = projectID
|
||||
? await serverSDK()
|
||||
.api.projectCopy.remove({ projectID, directory, force: false, location: { directory: root } })
|
||||
.then(() => true)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: errorMessage(err, language.t("common.requestFailed")),
|
||||
})
|
||||
return false
|
||||
})
|
||||
: false
|
||||
|
||||
setBusy(directory, false)
|
||||
|
||||
@@ -1469,20 +1445,8 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
platform,
|
||||
serverSDK().scope,
|
||||
)
|
||||
await serverSDK()
|
||||
.client.instance.dispose({ directory })
|
||||
.catch(() => undefined)
|
||||
|
||||
const result = await serverSDK()
|
||||
.client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("workspace.reset.failed.title"),
|
||||
description: errorMessage(err, language.t("common.requestFailed")),
|
||||
})
|
||||
return false
|
||||
})
|
||||
// TODO: Restore workspace reset and instance disposal when V2 exposes these operations.
|
||||
const result = false
|
||||
|
||||
if (!result) {
|
||||
setBusy(directory, false)
|
||||
@@ -1490,21 +1454,6 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
return
|
||||
}
|
||||
|
||||
if ((await serverSDK().protocol) === "v1")
|
||||
await Promise.all(
|
||||
sessions
|
||||
.filter((session) => session.time.archived === undefined)
|
||||
.map((session) =>
|
||||
serverSDK()
|
||||
.client.session.update({
|
||||
sessionID: session.id,
|
||||
directory: session.directory,
|
||||
time: { archived: Date.now() },
|
||||
})
|
||||
.catch(() => undefined),
|
||||
),
|
||||
)
|
||||
|
||||
setBusy(directory, false)
|
||||
dismiss()
|
||||
|
||||
@@ -1835,20 +1784,26 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
|
||||
const createWorkspace = async (project: LocalProject) => {
|
||||
clearSidebarHoverState()
|
||||
const created = await serverSDK()
|
||||
.client.worktree.create({ directory: project.worktree })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("workspace.create.failed.title"),
|
||||
description: errorMessage(err, language.t("common.requestFailed")),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
const created = project.id
|
||||
? await serverSDK()
|
||||
.api.projectCopy.create({
|
||||
projectID: project.id,
|
||||
strategy: "git_worktree",
|
||||
directory: getDirectory(project.worktree),
|
||||
location: { directory: project.worktree },
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("workspace.create.failed.title"),
|
||||
description: errorMessage(err, language.t("common.requestFailed")),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
: undefined
|
||||
|
||||
if (!created?.directory) return
|
||||
|
||||
setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch)
|
||||
setWorkspaceName(created.directory, getFilename(created.directory), project.id)
|
||||
|
||||
const local = project.worktree
|
||||
const key = pathKey(created.directory)
|
||||
@@ -2017,16 +1972,21 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
<div class="shrink-0 pl-1 py-1">
|
||||
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<InlineEditor
|
||||
id={`project:${projectId()}`}
|
||||
value={projectName}
|
||||
onSave={(next) => {
|
||||
void renameProject(project, next)
|
||||
}}
|
||||
class="text-14-medium text-text-strong truncate"
|
||||
displayClass="text-14-medium text-text-strong truncate"
|
||||
stopPropagation
|
||||
/>
|
||||
<Show
|
||||
when={!project.id || project.id === "global"}
|
||||
fallback={<span class="text-14-medium text-text-strong truncate">{projectName()}</span>}
|
||||
>
|
||||
<InlineEditor
|
||||
id={`project:${projectId()}`}
|
||||
value={projectName}
|
||||
onSave={(next) => {
|
||||
void renameProject(project, next)
|
||||
}}
|
||||
class="text-14-medium text-text-strong truncate"
|
||||
displayClass="text-14-medium text-text-strong truncate"
|
||||
stopPropagation
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
parseDeepLink,
|
||||
parseNewSessionDeepLink,
|
||||
} from "./deep-links"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Session } from "@/types"
|
||||
import {
|
||||
childSessionOnPath,
|
||||
closeHomeProject,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Session } from "@/types"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import type { HomeProjectSelection } from "@/context/layout"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Session } from "@/types"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
@@ -241,7 +241,8 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={!props.level}>
|
||||
{/* TODO: Restore the archive action when the V2 client exposes session archive. */}
|
||||
<Show when={false}>
|
||||
<div
|
||||
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
||||
classList={{
|
||||
|
||||
@@ -14,7 +14,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Session } from "@/types"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
import { useServerSync, useQueryOptions } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -200,8 +200,9 @@ const WorkspaceActions = (props: {
|
||||
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={props.local() || props.busy()}
|
||||
onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
|
||||
// TODO: Restore reset when V2 exposes project-copy reset and instance disposal.
|
||||
// onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
|
||||
disabled
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { FilePart, Project, UserMessage, VcsFileDiff } from "@/types"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
@@ -847,11 +847,8 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const gitMutation = useMutation(() => ({
|
||||
mutationFn: () => sdk().client.project.initGit(),
|
||||
onSuccess: (x) => {
|
||||
if (!x.data) return
|
||||
upsert(x.data)
|
||||
},
|
||||
// TODO: Restore Git initialization when the V2 client exposes this operation.
|
||||
mutationFn: async () => Promise.reject(new Error("Git initialization is unavailable")),
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
@@ -1217,11 +1214,7 @@ export default function Page() {
|
||||
{language.t("session.review.noVcs.createGit.description")}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
{/* TODO: Restore the init button when the V2 client exposes Git initialization. */}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1254,7 +1247,8 @@ export default function Page() {
|
||||
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
|
||||
}
|
||||
if (reviewMode() === "turn" && nogit()) {
|
||||
return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
|
||||
// TODO: Restore SessionReviewEmptyNoGitV2 when the V2 client exposes Git initialization.
|
||||
return empty(language.t("session.review.noVcs.createGit.description"))
|
||||
}
|
||||
return <SessionReviewEmptyChangesV2 />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@/types"
|
||||
import { todoDockAtBoundary, todoState } from "./session-composer-state"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionRequest, QuestionRequest, Todo } from "@/types"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionRequest } from "@/types"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@/types"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user