Compare commits

...

5 Commits

Author SHA1 Message Date
Brendan Allan 81d5a3973d fix(app): update remaining v2 e2e fixtures 2026-07-30 12:58:24 +08:00
Brendan Allan 35637a15c0 fix(app): migrate v2 test fixtures 2026-07-30 12:14:46 +08:00
Brendan Allan f2bfc7935c Merge remote-tracking branch 'origin/v2' into app-v2-only
# Conflicts:
#	packages/app/src/utils/server-compat.test.ts
#	packages/app/src/utils/server-compat.ts
2026-07-30 11:46:27 +08:00
Brendan Allan d4105e04e5 fix(app): own v2 rendering projections 2026-07-29 18:41:23 +08:00
Brendan Allan 689fb214e0 refactor(app): support only v2 servers 2026-07-29 16:47:59 +08:00
151 changed files with 1751 additions and 3053 deletions
-3
View File
@@ -64,7 +64,6 @@
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*", "@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4", "@pierre/trees": "1.0.0-beta.4",
@@ -6339,8 +6338,6 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/app/@opencode-ai/sdk": ["@opencode-ai/sdk@vendor/opencode-ai-sdk-1.18.8-dev.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-C2nfk4x0sPINwE5V6DPkFSuH3PkUmKPWHPzxpXC1j+3Ui5hslLCWJbkk8WcOG1Lyt3C0+yp4ea64v/kmtYCO4w=="],
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="], "@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
-220
View File
@@ -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 { Event } from "@opencode-ai/schema/event"
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event" import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1" import { SessionV1 } from "@opencode-ai/schema/session-v1"
import type { import type { SessionV1Info, SessionStatus } from "@opencode-ai/client/promise"
AssistantMessage, import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
GlobalEvent,
Message,
Part,
Session,
SessionStatus,
ToolPart,
ToolState,
UserMessage,
} from "@opencode-ai/sdk/v2/client"
import { expect, type Page } from "@playwright/test" import { expect, type Page } from "@playwright/test"
import { Schema } from "effect" import { Schema } from "effect"
import { mockOpenCodeServer } from "../../utils/mock-server" import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -27,18 +18,29 @@ export const assistantID = "msg_1001_timeline_assistant"
export const title = "Timeline visual stability" export const title = "Timeline visual stability"
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type TimelinePayload = Extract< type Session = SessionV1Info
GlobalEvent["payload"], type GlobalEvent = {
{ directory: string
type: project?: string
| "message.updated" workspace?: string
| "message.removed" payload: {
| "message.part.updated" id: string
| "message.part.removed" type: string
| "message.part.delta" properties: Record<string, unknown>
| "session.status"
} }
> }
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[] type DeepReadonly<Value> = Value extends readonly unknown[]
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> } ? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
@@ -97,7 +99,6 @@ export async function setupTimeline(
locale?: string locale?: string
deviceScaleFactor?: number deviceScaleFactor?: number
seedHistory?: boolean seedHistory?: boolean
protocol?: "v1" | "v2"
} = {}, } = {},
) { ) {
const sessions = input.sessions ?? [session()] const sessions = input.sessions ?? [session()]
@@ -115,7 +116,6 @@ export async function setupTimeline(
retry: input.eventRetry ?? 20, retry: input.eventRetry ?? 20,
}) })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: input.protocol,
directory, directory,
project: project(), project: project(),
provider: provider(), provider: provider(),
@@ -235,7 +235,7 @@ export function event(type: TimelinePayload["type"], properties: TimelinePayload
} }
export function validateTimelineEvent(input: unknown): TimelineEvent { export function validateTimelineEvent(input: unknown): TimelineEvent {
return decodeEvent(input, decodeOptions) return decodeEvent(input, decodeOptions) as TimelineEvent
} }
export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] { export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
@@ -460,7 +460,7 @@ export function toolPart(
input: Record<string, unknown>, input: Record<string, unknown>,
options: ToolOptions<ToolStatus> = {}, options: ToolOptions<ToolStatus> = {},
): Omit<ToolPart, "sessionID" | "messageID"> { ): Omit<ToolPart, "sessionID" | "messageID"> {
const base = { id, type: "tool" as const, callID: `call_${id}`, tool } const base = { id, type: "tool" as const, callID: id, tool }
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } } if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
if (state === "running") if (state === "running")
return { return {
@@ -1,6 +1,121 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Page, Route } from "@playwright/test" import type { Page, Route } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server" import { currentMessage, mockOpenCodeServer } from "../../utils/mock-server"
test("preserves current messages", () => {
const message = {
id: "msg_current",
type: "user",
time: { created: 1 },
text: "current",
files: [{ data: "e30=", mime: "application/json", source: { type: "inline" } }],
} satisfies SessionMessageInfo
expect(currentMessage(message)).toBe(message)
})
test("converts rich legacy messages to current message types", () => {
expect(
currentMessage({
info: { id: "msg_user", role: "user", time: { created: 1 } },
parts: [
{ type: "text", text: "Use @src/a.ts with @explore" },
{
type: "file",
mime: "application/json",
filename: "data.json",
url: "data:application/json;base64,e30=",
},
{
type: "file",
mime: "text/plain",
filename: "a.ts",
url: "src/a.ts",
source: { type: "file", text: { value: "@src/a.ts", start: 4, end: 13 } },
},
{ type: "agent", name: "explore", source: { value: "@explore", start: 19, end: 27 } },
],
}),
).toEqual({
id: "msg_user",
type: "user",
time: { created: 1 },
text: "Use @src/a.ts with @explore",
files: [
{ data: "e30=", mime: "application/json", name: "data.json", source: { type: "inline" } },
{
data: "",
mime: "text/plain",
name: "a.ts",
source: { type: "uri", uri: "src/a.ts" },
mention: { text: "@src/a.ts", start: 4, end: 13 },
},
],
agents: [{ name: "explore", mention: { text: "@explore", start: 19, end: 27 } }],
})
expect(
currentMessage({
info: {
id: "msg_assistant",
role: "assistant",
time: { created: 2, completed: 5 },
agent: "explore",
modelID: "model",
providerID: "provider",
variant: "high",
cost: 0.5,
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
finish: "tool-calls",
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
},
parts: [
{ type: "text", text: "Answer" },
{ type: "reasoning", text: "Thinking", time: { start: 2, end: 3 } },
{
id: "prt_tool",
callID: "call_tool",
type: "tool",
tool: "read",
state: {
status: "completed",
input: { filePath: "src/a.ts" },
output: "contents",
metadata: { title: "a.ts" },
time: { start: 3, end: 4 },
},
},
],
}),
).toEqual({
id: "msg_assistant",
type: "assistant",
time: { created: 2, completed: 5 },
agent: "explore",
model: { id: "model", providerID: "provider", variant: "high" },
cost: 0.5,
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
finish: "tool-calls",
error: { type: "MessageAbortedError", message: "Stopped" },
content: [
{ type: "text", text: "Answer" },
{ type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } },
{
type: "tool",
id: "call_tool",
name: "read",
time: { created: 3, ran: 3, completed: 4 },
state: {
status: "completed",
input: { filePath: "src/a.ts" },
content: [{ type: "text", text: "contents" }],
metadata: { title: "a.ts" },
},
},
],
})
})
test("applies message latency after a list response gate is released", async () => { test("applies message latency after a list response gate is released", async () => {
const events: string[] = [] const events: string[] = []
@@ -30,7 +145,7 @@ test("applies message latency after a list response gate is released", async ()
}) })
const response = handler!({ 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: () => { fulfill: () => {
events.push("fulfill") events.push("fulfill")
return Promise.resolve() return Promise.resolve()
@@ -85,21 +85,17 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) 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) 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/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) 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/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}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) 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 (/^\/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 (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, []) return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider") if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@@ -3,7 +3,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server" import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096" const serverA = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverB = "http://127.0.0.1:4097" const serverB = "http://127.0.0.1:4097"
const directoryA = "C:/server-a" const directoryA = "C:/server-a"
const directoryB = "/home/server-b" const directoryB = "/home/server-b"
@@ -32,7 +32,7 @@ test("session settings use the remote server context", async ({ page }) => {
.poll(() => .poll(() =>
permissionRequests.some((request) => { permissionRequests.some((request) => {
const url = new URL(request) const url = new URL(request)
return url.origin === serverB && url.searchParams.get("directory") === directoryB return url.origin === serverB && url.searchParams.get("location[directory]") === directoryB
}), }),
) )
.toBe(true) .toBe(true)
@@ -67,7 +67,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
.poll(() => .poll(() =>
permissionRequests.some((request) => { permissionRequests.some((request) => {
const url = new URL(request) const url = new URL(request)
return url.origin === serverA && url.searchParams.get("directory") === directoryA return url.origin === serverA && url.searchParams.get("location[directory]") === directoryA
}), }),
) )
.toBe(true) .toBe(true)
@@ -99,10 +99,10 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
.toEqual([ .toEqual([
{ {
origin: serverA, origin: serverA,
directory: directoryA, directory: undefined,
sessionID: sessionA.id, sessionID: sessionA.id,
permissionID: "permission-background-a", permissionID: "permission-background-a",
body: { response: "once" }, body: { reply: "once" },
}, },
]) ])
@@ -127,17 +127,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
.toEqual([ .toEqual([
{ {
origin: serverA, origin: serverA,
directory: directoryA, directory: undefined,
sessionID: sessionA.id, sessionID: sessionA.id,
permissionID: "permission-background-a", permissionID: "permission-background-a",
body: { response: "once" }, body: { reply: "once" },
}, },
{ {
origin: serverA, origin: serverA,
directory: directoryA, directory: undefined,
sessionID: childSessionA.id, sessionID: childSessionA.id,
permissionID: "permission-background-a-child", permissionID: "permission-background-a-child",
body: { response: "once" }, body: { reply: "once" },
}, },
]) ])
}) })
@@ -168,8 +168,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
const remote = url.origin === serverB const remote = url.origin === serverB
const directory = remote ? directoryB : directoryA const directory = remote ? directoryB : directoryA
const sessions = remote ? [sessionB] : [sessionA, childSessionA] const sessions = remote ? [sessionB] : [sessionA, childSessionA]
const requestDirectory = url.searchParams.get("directory") const requestDirectory = url.searchParams.get("location[directory]")
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/) const response = url.pathname.match(/^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/)
if (route.request().method() === "POST" && response) { if (route.request().method() === "POST" && response) {
permissionResponses.push({ permissionResponses.push({
origin: url.origin, origin: url.origin,
@@ -181,13 +181,21 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true) return json(route, true)
} }
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) 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) return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true }) if (url.pathname === "/api/provider")
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent") return json(route, {
return json(route, { data: [] }) location: { directory },
if (url.pathname === "/api/model/default") return json(route, { data: null }) data: [{ id: remote ? "server-b" : "server-a", name: remote ? "Server B Provider" : "Server A Provider", package: "test" }],
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname)) })
if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] })
if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) })
if (url.pathname === "/api/agent") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/permission/request") {
permissionRequests.push(url.toString())
return json(route, { location: { directory }, data: [] })
}
if (["/api/command", "/api/reference", "/api/question/request"].includes(url.pathname))
return json(route, { location: { directory }, data: [] }) return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] }) if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp/resource") if (url.pathname === "/api/mcp/resource")
@@ -211,8 +219,6 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`)) if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} }) 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\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
@@ -222,7 +228,6 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
} }
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname)) if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
return json(route, []) 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 === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (url.pathname === "/project" || url.pathname === "/project/current") { if (url.pathname === "/project" || url.pathname === "/project/current") {
@@ -288,6 +293,25 @@ function provider(id: string) {
} }
} }
function model(remote: boolean) {
const id = remote ? "server-b" : "server-a"
const name = remote ? "Server B" : "Server A"
return {
id,
modelID: id,
providerID: id,
name: `${name} Model`,
family: id,
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [],
time: { released: Date.now() },
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
status: "active",
enabled: true,
limit: { context: 200_000, output: 32_000 },
}
}
function json(route: Route, body: unknown, status = 200) { function json(route: Route, body: unknown, status = 200) {
return route.fulfill({ return route.fulfill({
status, status,
@@ -58,22 +58,18 @@ async function mockServers(page: Page) {
const current = url.origin === serverA ? sessionA : sessionB const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) 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") 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/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session/active") if (url.pathname === "/api/session/active")
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} }) 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") 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}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) 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 (/^\/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 (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, []) return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider") if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@@ -127,7 +127,7 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled() await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 }) expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click() await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
@@ -19,36 +19,25 @@ test("restores review mode and selected file per session", async ({ page }) => {
await expectSessionTitle(page, titleA) await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click() await page.getByRole("button", { name: "Toggle review" }).click()
await selectMode(page, "Git changes", "Branch changes") await selectFile(page, "alpha.ts")
await selectFile(page, "beta.ts")
await switchSession(page, titleB) await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await selectFile(page, "gamma.ts") await selectFile(page, "gamma.ts")
await switchSession(page, titleA) await switchSession(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts")
await selectMode(page, "Branch changes", "Git changes")
await expectSelectedFile(page, "alpha.ts") await expectSelectedFile(page, "alpha.ts")
await selectMode(page, "Git changes", "Branch changes")
await expectSelectedFile(page, "beta.ts")
await page.reload() await page.reload()
await expectSessionTitle(page, titleA) await expectSessionTitle(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible() await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts") await expectSelectedFile(page, "alpha.ts")
await switchSession(page, titleB) await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await expectSelectedFile(page, "gamma.ts") await expectSelectedFile(page, "gamma.ts")
}) })
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
await page.getByRole("option", { name: next }).dispatchEvent("click")
}
async function selectFile(page: Page, file: string) { async function selectFile(page: Page, file: string) {
await page.getByRole("button", { name: file }).click() await page.getByRole("button", { name: file }).click()
await expectSelectedFile(page, file) await expectSelectedFile(page, file)
@@ -65,7 +54,6 @@ async function switchSession(page: Page, title: string) {
async function setup(page: Page) { async function setup(page: Page) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v1",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -89,22 +77,27 @@ async function setup(page: Page) {
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
}) })
await page.route(/\/vcs(?:\?.*)?$/, (route) => await page.route(/\/api\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ branch: "feature", default_branch: "dev" }), body: JSON.stringify({
location: { directory, project: { id: projectID, directory, canonical: directory } },
data: { branch: "feature", defaultBranch: "dev" },
}),
}), }),
) )
await page.route("**/vcs/diff**", (route) => await page.route("**/api/vcs/diff**", (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify( body: JSON.stringify({
new URL(route.request().url()).searchParams.get("mode") === "branch" location: { directory, project: { id: projectID, directory, canonical: directory } },
? [diff("src/alpha.ts"), diff("src/beta.ts")] data:
: [diff("src/alpha.ts"), diff("src/gamma.ts")], new URL(route.request().url()).searchParams.get("mode") === "branch"
), ? [diff("src/alpha.ts"), diff("src/beta.ts")]
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
}),
}), }),
) )
await page.addInitScript( await page.addInitScript(
@@ -25,7 +25,6 @@ test("keeps the review tree and terminal sized when both panels are open", async
let detailFailures = 1 let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 }) await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v1",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -62,33 +61,32 @@ test("keeps the review tree and terminal sized when both panels are open", async
events: () => events.splice(0, 1), events: () => events.splice(0, 1),
eventRetry: 16, eventRetry: 16,
}) })
await page.route(/\/vcs(?:\?.*)?$/, (route) => await page.route(/\/api\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({
branch: "review-pane-performance", location: { directory, project: { id: projectID, directory, canonical: directory } },
default_branch: "dev", data: { branch: "review-pane-performance", defaultBranch: "dev" },
}), }),
}), }),
) )
await page.route("**/vcs/diff**", (route) => { await page.route("**/api/vcs/diff**", (route) => {
const url = new URL(route.request().url()) const url = new URL(route.request().url())
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/") const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/")
const detail = scope?.endsWith("/src/branch/d00027") const detail = scope?.endsWith("/src/branch/d00027")
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" }) if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify( body: JSON.stringify({
url.searchParams.get("mode") === "branch" location: { directory, project: { id: projectID, directory, canonical: directory } },
? detail data: detail
? branchDiffs ? branchDiffs
.filter((diff) => diff.file.startsWith("src/branch/d00027/")) .filter((diff) => diff.file.startsWith("src/branch/d00027/"))
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs : branchDiffs,
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)), }),
),
}) })
}) })
await page.route("**/pty*", (route) => await page.route("**/pty*", (route) =>
@@ -109,7 +107,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({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@@ -127,7 +125,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({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@@ -137,7 +135,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(() => { await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem( localStorage.setItem(
@@ -149,9 +147,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
await expect(page.locator("#review-panel")).toBeVisible() await expect(page.locator("#review-panel")).toBeVisible()
await expectTree(page, 8, "git-0.ts") await expectTree(page, 2_773, "action.yml")
await selectMode(page, "Git changes", "Branch changes")
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740") await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
await page.keyboard.press("Control+Backquote") await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible() await expect(page.locator("#terminal-panel")).toBeVisible()
@@ -174,9 +170,9 @@ test("keeps the review tree and terminal sized when both panels are open", async
expect(bottomGap).toBeLessThanOrEqual(16) expect(bottomGap).toBeLessThanOrEqual(16)
const lazyDiff = page.waitForRequest((request) => { const lazyDiff = page.waitForRequest((request) => {
const url = new URL(request.url()) const url = new URL(request.url())
return ( return (
url.pathname === "/vcs/diff" && url.pathname === "/api/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
) )
}) })
await lastFile.click() await lastFile.click()
@@ -190,59 +186,46 @@ test("keeps the review tree and terminal sized when both panels are open", async
const refreshedDiff = page.waitForRequest((request) => { const refreshedDiff = page.waitForRequest((request) => {
const url = new URL(request.url()) const url = new URL(request.url())
return ( return (
url.pathname === "/vcs/diff" && url.pathname === "/api/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
) )
}) })
sessionStatus[sessionID] = { type: "idle" } sessionStatus[sessionID] = { type: "idle" }
events.push(statusEvent("idle")) events.push(statusEvent("idle"))
await refreshedDiff await refreshedDiff
await expect(preview).toContainText("after-2") await expect(preview).toContainText("after-2")
await selectMode(page, "Branch changes", "Git changes")
await expectTree(page, 8, "git-0.ts")
await page.getByRole("button", { name: "git-0.ts" }).click()
await selectMode(page, "Git changes", "Branch changes")
await expectTree(page, 2_773, "action.yml")
const filter = page.getByRole("searchbox", { name: "Filter files" }) const filter = page.getByRole("searchbox", { name: "Filter files" })
await filter.fill("generated-2738") await filter.fill("generated-2738")
await expectTree(page, 1, "generated-2738.ts") await expectTree(page, 1, "generated-2738.ts")
await filter.fill("") await filter.fill("")
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "generated-2738.ts")
await page.getByRole("button", { name: "Toggle file tree" }).click() await page.getByRole("button", { name: "Toggle file tree" }).click()
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0) await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0) await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
await page.getByRole("button", { name: "Toggle file tree" }).click() await page.getByRole("button", { name: "Toggle file tree" }).click()
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "generated-2738.ts")
await page.keyboard.press("Control+Backquote") await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toHaveCount(0) await expect(page.locator("#terminal-panel")).toHaveCount(0)
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "generated-2738.ts")
await page.keyboard.press("Control+Backquote") await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible() await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "generated-2738.ts")
await page.getByRole("button", { name: "Toggle review" }).click() await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.locator("#review-panel")).toHaveCount(0) await expect(page.locator("#review-panel")).toHaveCount(0)
await page.getByRole("button", { name: "Toggle review" }).click() await page.getByRole("button", { name: "Toggle review" }).click()
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "generated-2738.ts")
await page.setViewportSize({ width: 1_000, height: 700 }) await page.setViewportSize({ width: 1_000, height: 700 })
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "generated-2738.ts")
await expectStackGeometry(page) await expectStackGeometry(page)
await page.setViewportSize({ width: 1_000, height: 120 }) await page.setViewportSize({ width: 1_000, height: 120 })
await page.setViewportSize({ width: 1_400, height: 900 }) await page.setViewportSize({ width: 1_400, height: 900 })
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "generated-2738.ts")
await expectStackGeometry(page) await expectStackGeometry(page)
}) })
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
const option = page.getByRole("option", { name: next })
await expect(option).toBeVisible()
await option.click()
}
async function expectTree(page: Page, total: number, file: string) { async function expectTree(page: Page, total: number, file: string) {
await expectMountedTree(page, total) await expectMountedTree(page, total)
await expect(page.getByRole("button", { name: file })).toBeVisible() await expect(page.getByRole("button", { name: file })).toBeVisible()
@@ -173,7 +173,6 @@ async function mockServer(
}, },
) { ) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -52,7 +52,7 @@ const editPart = {
sessionID, sessionID,
messageID: assistantMessageID, messageID: assistantMessageID,
type: "tool", type: "tool",
callID: "call_edit_regression", callID: editPartID,
tool: "edit", tool: "edit",
state: { state: {
status: "completed", status: "completed",
@@ -14,8 +14,8 @@ const projectID = "proj_context_resize_regression"
const sessionID = "ses_context_resize_regression" const sessionID = "ses_context_resize_regression"
const title = "Context resize regression" const title = "Context resize regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"] const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"]
const followingTextID = "prt_0104_text" const followingTextID = `${id("msg_assistant", 10)}:text:0`
type Message = { type Message = {
info: Record<string, unknown> & { id: string; role: "user" | "assistant" } info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
@@ -263,7 +263,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
), ),
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status), contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
{ {
id: followingTextID, id: "prt_0104_text",
sessionID, sessionID,
messageID: assistantID, messageID: assistantID,
type: "text", type: "text",
@@ -295,7 +295,7 @@ function contextTool(
sessionID, sessionID,
messageID, messageID,
type: "tool", type: "tool",
callID: `call_${partID}`, callID: partID,
tool, tool,
state: { state: {
status, status,
@@ -10,7 +10,6 @@ import {
status, status,
textPart, textPart,
title, title,
userID,
userMessage, userMessage,
} from "../performance/timeline-stability/fixture" } from "../performance/timeline-stability/fixture"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
@@ -19,18 +18,22 @@ import { expectSessionTitle } from "../utils/waits"
const initialPageSize = 20 const initialPageSize = 20
const historyPageSize = 200 const historyPageSize = 200
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, return [
parentID: userID, userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }),
created: 1700000001000 + index * 1_000, assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
completed: index < initialPageSize, id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
}), parentID: id,
) created: 1700000001000 + index * 2_000,
const messages = [userMessage(), ...assistants] completed: index < initialPageSize,
}),
]
}).flat()
const assistants = messages.filter((message) => message.info.role === "assistant")
const lastAssistant = assistants.at(-1)! const lastAssistant = assistants.at(-1)!
const lastPartID = assistants.at(-1)!.parts[0]!.id const lastPartID = `${assistants.at(-1)!.info.id}:text:0`
const userPartID = `prt_${userID}_text` const userPartID = `${messages.at(-2)!.info.id}:text:0`
const completed = { const completed = {
...lastAssistant.info, ...lastAssistant.info,
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 }, time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
@@ -154,15 +157,23 @@ for (const scenario of scenarios) {
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible() await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible() await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2) const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await viewport.hover()
const deadline = Date.now() + 10_000
while (requests.filter((request) => request.phase === "start").length < 2) {
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
await page.mouse.wheel(0, -240)
await page.waitForTimeout(20)
}
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1) expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
expect(sequence.slice(0, 4)).toEqual([ expect(sequence.slice(0, 3)).toEqual([
"messages:start:latest", "messages:start:latest",
"messages:end:latest", "messages:end:latest",
`message:${userID}`,
`messages:start:${messages.at(-initialPageSize)!.info.id}`, `messages:start:${messages.at(-initialPageSize)!.info.id}`,
]) ])
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize) await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount(
initialPageSize / 2,
)
await page.evaluate(() => { await page.evaluate(() => {
;( ;(
window as Window & { window as Window & {
@@ -174,7 +185,9 @@ for (const scenario of scenarios) {
expect(await visibleContentHidden(page)).toBe(false) expect(await visibleContentHidden(page)).toBe(false)
const beforeHistory = await probeSamples(page) const beforeHistory = await probeSamples(page)
history.resolve() history.resolve()
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length) await expect
.poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count())
.toBeGreaterThan(initialPageSize / 2)
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await waitForProbeSamples(page, beforeHistory) await waitForProbeSamples(page, beforeHistory)
@@ -182,7 +195,7 @@ for (const scenario of scenarios) {
{ before: undefined, limit: initialPageSize }, { before: undefined, limit: initialPageSize },
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
]) ])
expect(roots).toEqual([{ sessionID, messageID: userID }]) expect(roots).toEqual([])
const message = messageUpdated(scenario.info) const message = messageUpdated(scenario.info)
const idle = status("idle") const idle = status("idle")
@@ -103,7 +103,7 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
await timeline.send(status("idle"), 350) await timeline.send(status("idle"), 350)
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0) await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() await expect(page.locator('[data-timeline-part-id="prt_recovered"]')).toContainText("Recovered response")
}) })
function lines(count: number) { function lines(count: number) {
@@ -89,7 +89,6 @@ test.describe("session timeline projection", () => {
const aborted = assistantMessage( const aborted = assistantMessage(
[ [
{ id: "prt_before_abort", type: "text", text: "Before interruption" }, { id: "prt_before_abort", type: "text", text: "Before interruption" },
{ id: "prt_compaction", type: "compaction", auto: true },
], ],
{ {
id: "msg_1001_assistant_aborted", id: "msg_1001_assistant_aborted",
@@ -122,13 +121,13 @@ test.describe("session timeline projection", () => {
await scroller.evaluate((element) => (element.scrollTop = 0)) await scroller.evaluate((element) => (element.scrollTop = 0))
await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1) await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1)
await expect(page.getByText("Session compacted", { exact: true })).toBeVisible() await expect(page.getByText("Before interruption", { exact: true })).toBeVisible()
await expect(page.getByText("Visible provider failure")).toBeVisible() await expect(page.getByText("Visible provider failure")).toBeVisible()
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
}) })
test("renders comment strips and historical diff summary overflow", async ({ page }) => { test("renders legacy synthetic comments as ordinary V2 user text", async ({ page }) => {
const user = userMessage( const user = userMessage(
[ [
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", { userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
@@ -159,10 +158,14 @@ test.describe("session timeline projection", () => {
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0)) await scroller.evaluate((element) => (element.scrollTop = 0))
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toBeVisible() await expect(
await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible() page.getByText(
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() "The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable Continue after the comment",
await expect(page.getByText(/show all/i)).toBeVisible() { exact: true },
),
).toBeVisible()
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
}) })
test("renders interruption independently when the turn is not compacted", async ({ page }) => { test("renders interruption independently when the turn is not compacted", async ({ page }) => {
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import { import {
assistantID,
assistantMessage, assistantMessage,
reasoningPart, reasoningPart,
setupTimeline, setupTimeline,
@@ -70,7 +71,7 @@ for (const profile of profiles) {
await timeline.send(status("busy"), 150) await timeline.send(status("busy"), 150)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0) await expect(page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)).toHaveCount(profile.body ? 1 : 0)
if (!profile.summaries && profile.reasoning.trim()) { if (!profile.summaries && profile.reasoning.trim()) {
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible() await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
} }
@@ -89,5 +90,5 @@ test("does not infer reasoning visibility from provider identity", async ({ page
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0) await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible() await expect(page.locator(`[data-timeline-part-id="${assistantID}:text:0"]`)).toBeVisible()
}) })
@@ -23,10 +23,11 @@ test("groups singleton and separated context operations at correct boundaries",
] ]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible() await expect(
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible() page.locator('[data-timeline-part-ids="prt_boundary_01_read,prt_boundary_03_glob,prt_boundary_04_grep"]'),
).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible() await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5) await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(4)
}) })
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => { test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
@@ -145,7 +145,6 @@ test("allows paint rounding for every framed row but not fixed turn gaps", async
}), }),
], ],
}) })
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) => const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
@@ -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 }) => { 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")), { const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
id: "timeline-event-7", id: "timeline-event-7",
}) })
@@ -107,10 +107,10 @@ test("passes through non-event fetches", async ({ page }) => {
const timeline = await setupTimeline(page) const timeline = await setupTimeline(page)
const health = await page.evaluate(async () => { const health = await page.evaluate(async () => {
const response = await fetch("/global/health") const response = await fetch("/api/health")
return response.json() return response.json()
}) })
expect(health).toEqual({ healthy: true }) expect(health).toEqual({ healthy: true, version: "2.0.0", pid: 1 })
expect(await timeline.transport.connections()).toHaveLength(1) expect(await timeline.transport.connections()).toHaveLength(1)
}) })
@@ -23,7 +23,7 @@ type EventPayload = {
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" }) test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => { test("animates todo opening without replaying it across session tabs", async ({ page }) => {
test.setTimeout(90_000) test.setTimeout(90_000)
const events: EventPayload[] = [] const events: EventPayload[] = []
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] } const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
@@ -86,28 +86,8 @@ test("animates todo lifecycle without replaying it across session tabs", async (
await switchSession(page, otherID, otherTitle) await switchSession(page, otherID, otherTitle)
await expect(dock).toHaveCount(0) await expect(dock).toHaveCount(0)
const returningOpen = sampleDock(page, 700)
await switchSession(page, sourceID, sourceTitle)
const openSamples = (await returningOpen).filter((sample) => sample.present)
expect(openSamples.length).toBeGreaterThan(0)
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
expect(openSamples[0]!.height).toBeGreaterThan(70)
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
const closing = sampleDock(page, 1_000)
todos[sourceID] = completedTodos
events.push(todoEvent(sourceID, completedTodos))
await expect(dock).toHaveCount(0)
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
todos[sourceID] = []
events.push(todoEvent(sourceID, []))
await switchSession(page, otherID, otherTitle)
const returningEmpty = sampleDock(page, 700)
await switchSession(page, sourceID, sourceTitle) await switchSession(page, sourceID, sourceTitle)
await expect(dock).toHaveCount(0) await expect(dock).toHaveCount(0)
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
}) })
function session(id: string, title: string, created: number) { function session(id: string, title: string, created: number) {
@@ -89,23 +89,19 @@ async function mockServer(page: Page) {
if (url.origin !== server) return route.fallback() if (url.origin !== server) return route.fallback()
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname)) if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
return new Promise(() => {}) return new Promise(() => {})
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") if (url.pathname === "/api/event")
return sse(route) 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") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`) const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`)) if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
return json(route, { data: [], cursor: {} }) 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\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.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)) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, []) return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider") if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) 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 }) => { test("unmounts the terminal panel while it is hidden", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 }) await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -66,7 +66,6 @@ async function readProbe(page: Page) {
async function setup(page: Page) { async function setup(page: Page) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -21,7 +21,7 @@ const words = [
"vector", "vector",
] ]
const serverKey = "http://127.0.0.1:4096" const serverKey = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sourceID = "ses_smoke_source" const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target" const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject" const directory = "C:/OpenCode/SmokeProject"
@@ -134,7 +134,7 @@ function toolPart(
return { return {
id: id(`prt_tool_${tool}_${partIndex}`, index), id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool", type: "tool",
callID: id("call", index * 10 + partIndex), callID: id("call", index * 100 + partIndex),
tool, tool,
state: { state: {
status: "completed", status: "completed",
@@ -235,8 +235,17 @@ function renderable(part: MessagePart) {
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
} }
function orderedParts(message: Message) { function currentPartIDs(message: Message) {
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) const ordinals = { text: 0, reasoning: 0 }
return message.parts
.flatMap((part) => {
if (!renderable(part)) return []
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
return []
})
.sort()
} }
export const fixture = { export const fixture = {
@@ -290,12 +299,10 @@ export const fixture = {
targetMessageIDs: targetMessages targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user") .filter((message) => message.info.role === "user")
.map((message) => message.info.id), .map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) => targetPartIDs: targetMessages.flatMap(currentPartIDs),
orderedParts(message) expandedShellPartID: targetMessages
.filter(renderable) .flatMap((message) => message.parts)
.map((part) => part.id), .find((part) => part.tool === "bash")!.callID,
),
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
}, },
} }
@@ -125,20 +125,19 @@ test.describe("smoke: session timeline", () => {
}) })
await configureSmokePage(page, fixture.directory) await configureSmokePage(page, fixture.directory)
await page.addInitScript( await page.addInitScript(
({ dirBase64, sourceID, targetID }) => { ({ server, sourceID, targetID }) => {
localStorage.setItem( localStorage.setItem(
"opencode.window.browser.dat:tabs", "opencode.window.browser.dat:tabs",
JSON.stringify( JSON.stringify(
[sourceID, targetID].map((sessionId) => ({ [sourceID, targetID].map((sessionId) => ({
type: "session", type: "session",
server: "http://127.0.0.1:4096", server,
dirBase64,
sessionId, sessionId,
})), })),
), ),
) )
}, },
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, { server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
) )
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
@@ -251,20 +250,19 @@ test.describe("smoke: session timeline", () => {
}) })
await configureSmokePage(page, fixture.directory) await configureSmokePage(page, fixture.directory)
await page.addInitScript( await page.addInitScript(
({ dirBase64, sourceID, targetID }) => { ({ server, sourceID, targetID }) => {
localStorage.setItem( localStorage.setItem(
"opencode.window.browser.dat:tabs", "opencode.window.browser.dat:tabs",
JSON.stringify( JSON.stringify(
[sourceID, targetID].map((sessionId) => ({ [sourceID, targetID].map((sessionId) => ({
type: "session", type: "session",
server: "http://127.0.0.1:4096", server,
dirBase64,
sessionId, sessionId,
})), })),
), ),
) )
}, },
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, { server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
) )
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`) await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
await expectSessionTitle(page, fixture.expected.sourceTitle) await expectSessionTitle(page, fixture.expected.sourceTitle)
+1
View File
@@ -12,6 +12,7 @@
"./performance/unit/visual-stability.test.ts", "./performance/unit/visual-stability.test.ts",
"./reproduction/timeline-suspense/**/*.ts", "./reproduction/timeline-suspense/**/*.ts",
"./reproduction/timeline-suspense/**/*.tsx", "./reproduction/timeline-suspense/**/*.tsx",
"../src/types.ts",
"../src/pages/session/timeline/observe-element-offset.ts", "../src/pages/session/timeline/observe-element-offset.ts",
"./regression/new-session-panel-corner.spec.ts", "./regression/new-session-panel-corner.spec.ts",
"./regression/session-timeline-context-resize.spec.ts", "./regression/session-timeline-context-resize.spec.ts",
@@ -4,11 +4,7 @@ import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/NewProject" const directory = "C:/OpenCode/NewProject"
test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => { test("creates a session in a new project and selects its model", async ({ page }) => {
let connectedGo = false
let pendingGo = false
const connections: Array<{ integrationID: string; body: unknown }> = []
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
directory, directory,
project: { project: {
@@ -46,17 +42,9 @@ test("creates a session in a new project, connects OpenCode Go, and selects its
}, },
}, },
], ],
connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"], connected: ["opencode", "opencode-go"],
default: { providerID: "opencode", modelID: "free-model" }, default: { providerID: "opencode", modelID: "free-model" },
}), }),
integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
onConnectKey: (input) => {
connections.push(input)
if (input.integrationID === "opencode-go") pendingGo = true
},
onInstanceDispose: () => {
if (pendingGo) connectedGo = true
},
sessions: [], sessions: [],
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
fileList: (path) => fileList: (path) =>
@@ -66,6 +54,17 @@ test("creates a session in a new project, connects OpenCode Go, and selects its
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } })) localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
user: [
{ providerID: "opencode", modelID: "free-model", visibility: "show" },
{ providerID: "opencode-go", modelID: "go-model-1", visibility: "show" },
],
recent: [],
variant: {},
}),
)
}) })
await page.goto("/") await page.goto("/")
@@ -79,16 +78,7 @@ test("creates a session in a new project, connects OpenCode Go, and selects its
const modelControl = page.locator('[data-action="prompt-model"]') const modelControl = page.locator('[data-action="prompt-model"]')
await modelControl.click() await modelControl.click()
await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode") await expect(page.locator('[data-option-key="opencode:free-model"]')).toBeVisible()
await page.locator('[data-provider-id="opencode-go"]').click()
await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
await page.locator('[data-action="provider-connect-submit"]').click()
await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }])
await expect(modelControl).toHaveAttribute("data-control-type", "popover")
await modelControl.click()
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]') const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
await expect(goModel).toBeVisible() await expect(goModel).toBeVisible()
await goModel.click() await goModel.click()
+351 -172
View File
@@ -1,14 +1,17 @@
import type { Page, Route } from "@playwright/test" import type { Page, Route } from "@playwright/test"
import type {
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"]) JsonValue,
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) PromptAgentAttachment,
PromptFileAttachment,
SessionMessageAssistant,
SessionMessageInfo,
SessionStructuredError,
} from "@opencode-ai/client/promise"
export interface MockServerConfig { export interface MockServerConfig {
protocol?: "v1" | "v2"
provider: unknown | (() => unknown) provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]> integrationMethods?: Record<string, unknown[]>
onConnectKey?: (input: { integrationID: string; body: unknown }) => void onConnectKey?: (input: { integrationID: string; body: unknown }) => void
onInstanceDispose?: () => void
directory: string directory: string
project: unknown project: unknown
sessions: ({ id: string } & Record<string, unknown>)[] sessions: ({ id: string } & Record<string, unknown>)[]
@@ -33,21 +36,6 @@ export interface MockServerConfig {
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>() const cursors = new Map<string, string>()
let nextCursor = 0 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) => { await page.route("**/*", async (route) => {
const url = new URL(route.request().url()) const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
@@ -57,61 +45,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (url.port !== targetPort && url.port !== appPort) return route.fallback() if (url.port !== targetPort && url.port !== appPort) return route.fallback()
const path = url.pathname const path = url.pathname
if (path === "/global/event" || path === "/event" || path === "/api/event") { if (path === "/api/event") {
const events = config.events?.() const events = config.events?.()
return sse( return sse(
route, route,
path === "/api/event" [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])],
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
: [
...(path === "/global/event"
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
: []),
...(events ?? []),
],
config.eventRetry, config.eventRetry,
) )
} }
if (path === "/global/health") if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
if (path === "/api/health" && 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/reference") if (path === "/api/reference")
return json(route, { return json(route, {
location: { location: {
@@ -134,7 +76,17 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}, },
], ],
}) })
if (path === "/api/provider")
return json(route, {
location: location(config),
data: currentProviders(providerConfig(config)),
})
if (path === "/api/model") return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
if (path === "/api/model/default")
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
if (path === "/api/command") return json(route, { location: location(config), data: [] }) 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") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp/resource") if (path === "/api/mcp/resource")
return json(route, { location: location(config), data: { resources: [], templates: [] } }) return json(route, { location: location(config), data: { resources: [], templates: [] } })
@@ -142,25 +94,31 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (integration && route.request().method() === "GET") if (integration && route.request().method() === "GET")
return json(route, { return json(route, {
location: location(config), location: location(config),
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] }, data: {
id: integration,
name: integration,
methods: config.integrationMethods?.[integration] ?? [{ type: "key", label: "API key" }],
connections: [],
},
}) })
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1] const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
if (integrationConnect && route.request().method() === "POST") { if (integrationConnect && route.request().method() === "POST") {
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() }) config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) 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") return json(route, [config.project])
if (path === "/api/project/current") if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory }) 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/location") return json(route, location(config))
if (path === "/api/path") const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
return json(route, { if (projectCopy && route.request().method() === "POST") {
state: config.directory, const input = route.request().postDataJSON() as { directory: string; name?: string }
config: config.directory, return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
worktree: config.directory, }
directory: config.directory, if (projectCopy && route.request().method() === "DELETE")
home: "C:/OpenCode", return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
})
if (path === "/api/permission/request") if (path === "/api/permission/request")
return json(route, { return json(route, {
location: location(config), location: location(config),
@@ -177,11 +135,43 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } }) 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/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/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
if (path === "/api/fs/list" && config.fileList)
return json(route, {
location: location(config),
data: await config.fileList(url.searchParams.get("path") ?? ""),
})
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
if (fileRead && config.fileContent) {
const value = await config.fileContent(decodeURIComponent(fileRead))
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/fs/find" && config.findFiles) {
const entries = 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,
})
return json(route, {
location: location(config),
data: Array.isArray(entries)
? entries.map((entry) =>
typeof entry === "string"
? {
name: entry.split(/[\\/]/).at(-1) ?? entry,
path: entry,
absolute: `${config.directory}/${entry}`,
type: "directory",
ignored: false,
}
: entry,
)
: entries,
})
}
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] }) if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path)) if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }) 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") { if (path === "/api/session") {
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
const parentID = url.searchParams.get("parentID") const parentID = url.searchParams.get("parentID")
@@ -208,7 +198,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}) })
} }
if (path === "/api/session/active") { if (path === "/api/session/active") {
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }> const statuses = (
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
) as Record<string, { type?: string }>
return json(route, { return json(route, {
data: Object.fromEntries( data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) => Object.entries(statuses).flatMap(([id, status]) =>
@@ -226,12 +218,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) 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 ( if (
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
route.request().method() === "POST" route.request().method() === "POST"
@@ -241,7 +227,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") { if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) 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\/([^/]+)$/) const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
if (currentSessionMatch) { if (currentSessionMatch) {
@@ -252,28 +237,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}) })
} }
const sessionMatch = path.match(/^\/session\/([^/]+)$/) const currentMessageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
if (sessionMatch) { if (currentMessageMatch) {
const session = config.sessions.find((s) => s.id === sessionMatch[1]) config.onMessage?.({ sessionID: currentMessageMatch[1]!, messageID: currentMessageMatch[2]! })
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]! })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) 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) if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
return json(route, message) return json(route, { data: 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$/) const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
if (currentMessagesMatch) { if (currentMessagesMatch) {
const token = url.searchParams.get("cursor") ?? undefined const token = url.searchParams.get("cursor") ?? undefined
@@ -292,23 +264,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, {}) if (url.port === targetPort && targetPort !== appPort) return json(route, {})
return route.fallback() return route.fallback()
}) })
@@ -317,10 +272,75 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
function location(config: MockServerConfig) { function location(config: MockServerConfig) {
return { return {
directory: config.directory, directory: config.directory,
project: { id: (config.project as { id?: string }).id, directory: config.directory }, project: { id: (config.project as { id?: string }).id, directory: config.directory, canonical: config.directory },
} }
} }
function providerConfig(config: MockServerConfig) {
return typeof config.provider === "function" ? config.provider() : config.provider
}
function currentProviders(value: unknown) {
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
return value.all.filter(record).flatMap((provider) =>
typeof provider.id === "string" && typeof provider.name === "string"
? [{ id: provider.id, name: provider.name, package: provider.id }]
: [],
)
}
function currentModels(value: unknown) {
if (!record(value) || !Array.isArray(value.all)) return []
return value.all.filter(record).flatMap((provider) => {
if (typeof provider.id !== "string" || !record(provider.models)) return []
return Object.values(provider.models)
.filter(record)
.flatMap((model) => {
if (typeof model.id !== "string" || typeof model.name !== "string") return []
const limit = record(model.limit) ? model.limit : {}
const cost = record(model.cost) ? model.cost : {}
return [
{
id: model.id,
modelID: model.id,
providerID: provider.id,
name: model.name,
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: record(model.variants)
? Object.entries(model.variants).map(([id, settings]) => ({
id,
...(jsonRecord(settings) ? { settings: jsonRecord(settings) } : {}),
}))
: [],
time: { released: Date.now() },
cost: [
{
input: typeof cost.input === "number" ? cost.input : 0,
output: typeof cost.output === "number" ? cost.output : 0,
cache: { read: 0, write: 0 },
},
],
status: "active",
enabled: true,
limit: {
context: typeof limit.context === "number" ? limit.context : 200_000,
output: typeof limit.output === "number" ? limit.output : 32_000,
},
},
]
})
})
}
function currentDefaultModel(value: unknown) {
if (!record(value) || !record(value.default)) return null
const selected = value.default
const models = currentModels(value)
return models.find(
(model) => model.providerID === selected.providerID && model.id === selected.modelID,
) ?? null
}
function currentPermission(value: unknown) { function currentPermission(value: unknown) {
const permission = value as Record<string, unknown> const permission = value as Record<string, unknown>
if (permission.action) return permission if (permission.action) return permission
@@ -364,65 +384,224 @@ export function currentSession(session: { id: string } & Record<string, unknown>
} }
} }
function currentMessage(value: unknown) { export function currentMessage(value: unknown): SessionMessageInfo {
const item = value as { if (isCurrentMessage(value)) return value
info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } } if (!record(value) || !record(value.info) || !Array.isArray(value.parts)) throw new Error("Invalid message fixture")
parts: Array<Record<string, unknown> & { type: string }>
const info = value.info
const parts = value.parts.filter(record)
if (typeof info.id !== "string" || !record(info.time) || typeof info.time.created !== "number")
throw new Error("Invalid legacy message fixture")
const time = {
created: info.time.created,
...(typeof info.time.completed === "number" ? { completed: info.time.completed } : {}),
} }
if (item.info.role === "user") { if (info.role === "user") {
return { return {
id: item.info.id, id: info.id,
type: "user", type: "user",
time: item.info.time, time: { created: time.created },
text: item.parts text: parts
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])) .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
.join("\n"), .join("\n"),
files: parts.flatMap((part) => (part.type === "file" ? legacyFile(part) : [])),
agents: parts.flatMap((part) => (part.type === "agent" ? legacyAgent(part) : [])),
} }
} }
if (info.role !== "assistant") throw new Error("Invalid legacy message role")
return { return {
id: item.info.id, id: info.id,
type: "assistant", type: "assistant",
time: item.info.time, time,
agent: item.info.agent ?? "build", agent: typeof info.agent === "string" ? info.agent : typeof info.mode === "string" ? info.mode : "build",
model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" }, model: {
cost: item.info.cost, id: typeof info.modelID === "string" ? info.modelID : "model",
tokens: item.info.tokens, providerID: typeof info.providerID === "string" ? info.providerID : "provider",
error: item.info.error, ...(typeof info.variant === "string" ? { variant: info.variant } : {}),
content: item.parts.flatMap<unknown>((part) => { },
if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }] content: parts.flatMap((part) => legacyAssistantContent(part, time.created)),
if (part.type !== "tool") return [] ...(typeof info.cost === "number" ? { cost: info.cost } : {}),
const state = part.state as Record<string, unknown> ...(tokens(info.tokens) ? { tokens: tokens(info.tokens) } : {}),
return [ ...(structuredError(info.error) ? { error: structuredError(info.error) } : {}),
{ ...(finish(info.finish) ? { finish: finish(info.finish) } : {}),
type: "tool",
id: part.id,
name: part.tool,
time: state.time ?? { created: item.info.time.created },
state:
state.status === "pending"
? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
: state.status === "completed"
? {
status: "completed",
input: state.input ?? {},
structured: state.metadata ?? {},
content: [{ type: "text", text: state.output ?? "" }],
}
: state.status === "error"
? {
status: "error",
input: state.input ?? {},
structured: state.metadata ?? {},
content: [],
error: { type: "ToolError", message: state.error ?? "Tool failed" },
}
: { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
},
]
}),
} }
} }
function isCurrentMessage(value: unknown): value is SessionMessageInfo {
return record(value) && typeof value.id === "string" && typeof value.type === "string" && !record(value.info)
}
function legacyFile(part: Record<string, unknown>): PromptFileAttachment[] {
if (typeof part.mime !== "string" || typeof part.url !== "string") return []
const data = part.url.match(/^data:[^,]*;base64,(.*)$/)?.[1] ?? ""
const source = record(part.source) ? part.source : undefined
const sourceText = source && record(source.text) ? source.text : undefined
const mention = mentionFrom(sourceText)
const uri = source?.type === "resource" && typeof source.uri === "string" ? source.uri : part.url
return [
{
data,
mime: part.mime,
source: part.url.startsWith("data:") ? { type: "inline" } : { type: "uri", uri },
...(typeof part.filename === "string" ? { name: part.filename } : {}),
...(mention ? { mention } : {}),
},
]
}
function legacyAgent(part: Record<string, unknown>): PromptAgentAttachment[] {
if (typeof part.name !== "string") return []
const mention = mentionFrom(record(part.source) ? part.source : undefined)
return [{ name: part.name, ...(mention ? { mention } : {}) }]
}
function mentionFrom(value: Record<string, unknown> | undefined) {
if (
!value ||
typeof value.value !== "string" ||
typeof value.start !== "number" ||
typeof value.end !== "number"
)
return
return { text: value.value, start: value.start, end: value.end }
}
function legacyAssistantContent(
part: Record<string, unknown>,
created: number,
): SessionMessageAssistant["content"] {
if (part.type === "text" && typeof part.text === "string")
return [{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) }]
if (part.type === "reasoning" && typeof part.text === "string") {
const time = record(part.time) ? part.time : undefined
return [
{
type: "reasoning",
text: part.text,
...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}),
...(time && typeof time.start === "number"
? {
time: {
created: time.start,
...(typeof time.end === "number" ? { completed: time.end } : {}),
},
}
: {}),
},
]
}
if (part.type !== "tool" || typeof part.id !== "string" || typeof part.tool !== "string" || !record(part.state))
return []
const state = part.state
const time = record(state.time) ? state.time : undefined
const toolTime = {
created: time && typeof time.start === "number" ? time.start : created,
...(time && typeof time.start === "number" ? { ran: time.start } : {}),
...(time && typeof time.end === "number" ? { completed: time.end } : {}),
}
const input = jsonRecord(state.input) ?? {}
const metadata = jsonRecord(state.metadata)
const base = {
type: "tool" as const,
id: typeof part.callID === "string" ? part.callID : part.id,
name: part.tool,
time: toolTime,
...(typeof part.executed === "boolean" ? { executed: part.executed } : {}),
...(jsonRecord(part.providerState) ? { providerState: jsonRecord(part.providerState) } : {}),
...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
}
if (state.status === "pending")
return [{ ...base, state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) } }]
if (state.status === "completed")
return [
{
...base,
state: {
status: "completed",
input,
content: [{ type: "text", text: typeof state.output === "string" ? state.output : "" }],
...(metadata ? { metadata } : {}),
},
},
]
if (state.status === "error")
return [
{
...base,
state: {
status: "error",
input,
error: structuredError(state.error) ?? { type: "ToolError", message: "Tool failed" },
...(metadata ? { metadata } : {}),
},
},
]
return [{ ...base, state: { status: "running", input, metadata: metadata ?? {} } }]
}
function structuredError(value: unknown): SessionStructuredError | undefined {
if (typeof value === "string") return { type: "Error", message: value }
if (!record(value)) return
if (typeof value.type === "string" && typeof value.message === "string")
return { type: value.type, message: value.message }
if (typeof value.name !== "string" || !record(value.data) || typeof value.data.message !== "string") return
return { type: value.name, message: value.data.message }
}
function tokens(value: unknown): SessionMessageAssistant["tokens"] | undefined {
if (!record(value) || !record(value.cache)) return
if (
typeof value.input !== "number" ||
typeof value.output !== "number" ||
typeof value.reasoning !== "number" ||
typeof value.cache.read !== "number" ||
typeof value.cache.write !== "number"
)
return
return {
input: value.input,
output: value.output,
reasoning: value.reasoning,
cache: { read: value.cache.read, write: value.cache.write },
}
}
function finish(value: unknown): SessionMessageAssistant["finish"] | undefined {
if (
value === "stop" ||
value === "length" ||
value === "tool-calls" ||
value === "content-filter" ||
value === "error" ||
value === "unknown"
)
return value
}
function jsonRecord(value: unknown): Record<string, JsonValue> | undefined {
if (!record(value)) return
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => {
const next = jsonValue(item)
return next === undefined ? [] : [[key, next]]
}),
)
}
function jsonValue(value: unknown): JsonValue | undefined {
if (value === null || typeof value === "string" || typeof value === "boolean") return value
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (Array.isArray(value)) return value.map((item) => jsonValue(item) ?? null)
return jsonRecord(value)
}
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) { function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
return route.fulfill({ return route.fulfill({
status, status,
+5 -17
View File
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
export type SseConnectionRecord = { export type SseConnectionRecord = {
id: number id: number
url: string url: string
path: "/global/event" | "/event" | "/api/event" path: "/api/event"
headers: Record<string, string> headers: Record<string, string>
openedAt: number openedAt: number
endedAt?: number endedAt?: number
@@ -174,10 +174,7 @@ export async function installSseTransport<T>(
const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init) const request = new Request(input, init)
const url = new URL(request.url) const url = new URL(request.url)
if ( if (url.origin !== server || url.pathname !== "/api/event")
url.origin !== server ||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
)
return originalFetch(request) return originalFetch(request)
const id = ++nextConnectionID const id = ++nextConnectionID
@@ -193,18 +190,9 @@ export async function installSseTransport<T>(
record.controller = controller record.controller = controller
connections.push(record) connections.push(record)
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
if (url.pathname === "/api/event") controller.enqueue(
controller.enqueue( encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), )
)
if (url.pathname === "/global/event")
controller.enqueue(
encoder.encode(
frame({
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
}),
),
)
request.signal.addEventListener( request.signal.addEventListener(
"abort", "abort",
() => { () => {
-1
View File
@@ -56,7 +56,6 @@
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*", "@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4", "@pierre/trees": "1.0.0-beta.4",
-20
View File
@@ -238,26 +238,6 @@ function UiI18nBridge(props: ParentProps) {
} }
function LayoutCompatibility(props: ParentProps) { function LayoutCompatibility(props: ParentProps) {
const global = useGlobal()
const navigate = useNavigate()
const server = useServer()
const settings = useSettings()
createEffect(() => {
if (settings.general.newLayoutDesigns()) return
const current = server.current
if (!current) return
const protocol = global.ensureServerCtx(current).sdk.protocolKind()
if (protocol !== "v2") return
const next = global.servers.list().find((s) => {
if (ServerConnection.key(s) === ServerConnection.key(current)) return false
return global.ensureServerCtx(s).sdk.protocolKind() !== "v2"
})
if (!next) return
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(next)))
})
return <>{props.children}</> return <>{props.children}</>
} }
@@ -1,5 +1,5 @@
import { getFilename } from "@opencode-ai/core/util/path" 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 type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createMemo, onCleanup } from "solid-js" import { createMemo, onCleanup } from "solid-js"
@@ -130,26 +130,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
} }
const saveMutation = useMutation(() => ({ const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => { mutationFn: async (result: NonNullable<ReturnType<typeof validate>>): Promise<typeof result> => {
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server") // TODO: Restore custom providers when V2 exposes config and arbitrary credential APIs.
const disabledProviders = serverSync().data.config.disabled_providers ?? [] throw new Error(`Custom provider ${result.providerID} is unavailable`)
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
if (result.key) {
await serverSDK().client.auth.set({
providerID: result.providerID,
auth: {
type: "api",
key: result.key,
},
})
}
await serverSync().updateConfig({
provider: { [result.providerID]: result.config },
disabled_providers: nextDisabled,
})
return result
}, },
onSuccess: (result) => { onSuccess: (result) => {
dialog.close() dialog.close()
@@ -146,7 +146,7 @@ export function DialogEditProjectV2(props: { project: LocalProject; server: Serv
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}> <ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")} {language.t("common.cancel")}
</ButtonV2> </ButtonV2>
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}> <ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")} {model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</ButtonV2> </ButtonV2>
</DialogFooter> </DialogFooter>
@@ -160,7 +160,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<Button type="button" variant="ghost" size="large" onClick={model.close}> <Button type="button" variant="ghost" size="large" onClick={model.close}>
{language.t("common.cancel")} {language.t("common.cancel")}
</Button> </Button>
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}> <Button type="submit" variant="primary" size="large" disabled={!model.supported || model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")} {model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</Button> </Button>
</div> </div>
+1 -1
View File
@@ -8,7 +8,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { extractPromptFromParts } from "@/utils/prompt" 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 { base64Encode } from "@opencode-ai/core/util/encode"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -8,7 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
import type { Path } from "@opencode-ai/sdk/v2/client" import type { Path } from "@/types"
import { import {
absoluteTreePath, absoluteTreePath,
activeTreeNavigation, activeTreeNavigation,
@@ -66,16 +66,21 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
let pathArea: HTMLDivElement | undefined let pathArea: HTMLDivElement | undefined
let navigation = 0 let navigation = 0
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource( const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined), () => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
async (): Promise<Path | undefined> => { () =>
if ((await sdk.protocol) !== "v1") return sdk.api.location
return sdk.client.path
.get() .get()
.then((result) => result.data) .then(
.catch(() => undefined) (location): Path => ({
}, state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}),
)
.catch(() => undefined),
{ initialValue: undefined }, { initialValue: undefined },
) )
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
@@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain" import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
import type { Path } from "@opencode-ai/sdk/v2/client" import type { Path } from "@/types"
interface DialogSelectDirectoryProps { interface DialogSelectDirectoryProps {
title?: string title?: string
@@ -57,16 +57,21 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [filter, setFilter] = createSignal("") const [filter, setFilter] = createSignal("")
let list: ListRef | undefined let list: ListRef | undefined
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource( const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined), () => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
async (): Promise<Path | undefined> => { () =>
if ((await sdk.protocol) !== "v1") return sdk.api.location
return sdk.client.path
.get() .get()
.then((result) => result.data) .then(
.catch(() => undefined) (location): Path => ({
}, state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}),
)
.catch(() => undefined),
{ initialValue: undefined }, { initialValue: undefined },
) )
@@ -16,7 +16,6 @@ import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { detectServerProtocol } from "@/utils/server-protocol"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
@@ -264,13 +263,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
setStore("addServer", { error: language.t("dialog.server.add.error") }) setStore("addServer", { error: language.t("dialog.server.add.error") })
return return
} }
if (
!settings.general.newLayoutDesigns() &&
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd() resetAdd()
if (options.navigateOnAdd === false) { if (options.navigateOnAdd === false) {
@@ -315,13 +307,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
setStore("editServer", { error: language.t("dialog.server.add.error") }) setStore("editServer", { error: language.t("dialog.server.add.error") })
return return
} }
if (
!settings.general.newLayoutDesigns() &&
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) { if (normalized === input.original.http.url) {
server.add(conn) server.add(conn)
} else { } else {
@@ -360,9 +345,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
const sortedItems = createMemo(() => { const sortedItems = createMemo(() => {
const raw = items() const raw = items()
const list = settings.general.newLayoutDesigns() const list = raw
? raw
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
if (!list.length) return list if (!list.length) return list
const active = current() const active = current()
const order = new Map(list.map((url, index) => [url, index] as const)) const order = new Map(list.map((url, index) => [url, index] as const))
+6 -24
View File
@@ -9,6 +9,7 @@ import { type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) { export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const supported = !props.project.id || props.project.id === "global"
const dialog = useDialog() const dialog = useDialog()
const global = useGlobal() const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
@@ -71,29 +72,9 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim() const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") { if (props.project.id && props.project.id !== "global") {
if ((await serverCtx().sdk.protocol) !== "v1") return // TODO: Restore project edits when the V2 client exposes a project update API.
const project = await serverCtx() // await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands })
.sdk.client.project.update({ throw new Error(`Project ${props.project.id} cannot be updated`)
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
.then((result) => result.data)
if (!project) return
// const project = await serverCtx().sdk.api.project.update({
// projectID: props.project.id,
// name,
// icon: { color: store.color || "", override: store.iconOverride || "" },
// commands: { start },
// })
serverCtx().sync.set("project", (items) =>
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
)
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
} }
serverCtx().sync.project.meta(props.project.worktree, { serverCtx().sync.project.meta(props.project.worktree, {
@@ -107,7 +88,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
function submit(event: SubmitEvent) { function submit(event: SubmitEvent) {
event.preventDefault() event.preventDefault()
if (save.isPending) return if (!supported || save.isPending) return
save.mutate() save.mutate()
} }
@@ -117,6 +98,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
folderName, folderName,
defaultName, defaultName,
save, save,
supported,
submit, submit,
drop, drop,
dragOver, dragOver,
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model" import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
import type { FileNode } from "@opencode-ai/sdk/v2" import type { FileNode } from "@/types"
describe("buildFileTreeV2Model", () => { describe("buildFileTreeV2Model", () => {
test("builds a sorted tree and flattens expanded directories", () => { 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 = { export type FileTreeV2Model = {
children: ReadonlyMap<string, readonly FileTreeV2Node[]> children: ReadonlyMap<string, readonly FileTreeV2Node[]>
+1 -1
View File
@@ -12,7 +12,7 @@ import {
type ParentProps, type ParentProps,
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" 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 { Icon } from "@opencode-ai/ui/v2/icon"
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
+1 -1
View File
@@ -17,7 +17,7 @@ import {
type ParentProps, type ParentProps,
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2" import type { FileNode } from "@/types"
const MAX_DEPTH = 128 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 { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-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 { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
@@ -1,6 +1,6 @@
// @ts-nocheck // @ts-nocheck
import { createStore } from "solid-js/store" 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 { createPromptState } from "@/context/prompt"
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer" import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
import { createPromptInputHistory, PromptInput } from "./prompt-input" import { createPromptInputHistory, PromptInput } from "./prompt-input"
+1 -1
View File
@@ -81,7 +81,7 @@ import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/place
import { createPromptInputTransientState } from "./prompt-input/transient-state" import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview" 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 { createPromptInputHistory }
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission } export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
@@ -1,5 +1,5 @@
import { getFilename } from "@opencode-ai/core/util/path" 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 type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path" import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
@@ -125,13 +125,6 @@ beforeAll(async () => {
useSearchParams: () => [search, () => undefined], useSearchParams: () => [search, () => undefined],
})) }))
mock.module("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: (input: { directory: string }) => {
createdClients.push(input.directory)
return clientFor(input.directory)
},
}))
mock.module("@opencode-ai/ui/toast", () => ({ mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null }, Toast: { Region: () => null },
showToast: () => 0, showToast: () => 0,
@@ -197,12 +190,8 @@ beforeAll(async () => {
const sdk = { const sdk = {
scope: "local", scope: "local",
directory: "/repo/main", directory: "/repo/main",
client: rootClient,
api: rootClient.api, api: rootClient.api,
url: "http://localhost:4096", url: "http://localhost:4096",
createClient(opts: any) {
return clientFor(opts.directory)
},
} }
return () => sdk return () => sdk
}, },
@@ -332,7 +321,7 @@ describe("prompt submit worktree selection", () => {
selected = "/repo/worktree-b" selected = "/repo/worktree-b"
await submit.handleSubmit(event) 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(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sessionCreateInputs).toEqual([ expect(sessionCreateInputs).toEqual([
{ {
@@ -489,9 +478,6 @@ describe("prompt submit worktree selection", () => {
agents: [], agents: [],
}) })
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_") 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 () => { 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 { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary" 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 { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import { getDirectory } from "@opencode-ai/core/util/path"
import { buildRequestParts } from "./build-request-parts" import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
@@ -159,10 +160,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
await input.api.prompt({ await input.api.prompt({
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
id: messageID, 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"), text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
files: requestParts.flatMap((part) => { files: requestParts.flatMap((part) => {
if (part.type !== "file") return [] if (part.type !== "file") return []
@@ -348,13 +345,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const worktreeSelection = input.newSessionWorktree?.() || "main" const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory let sessionDirectory = projectDirectory
let client = sdk().client
if (isNewSession) { if (isNewSession) {
if (worktreeSelection === "create") { if (worktreeSelection === "create") {
const createdWorktree = await client.worktree const createdWorktree = await sdk()
.create({ directory: projectDirectory }) .api.projectCopy.create({
.then((x) => x.data) projectID: sync().data.project,
strategy: "git_worktree",
directory: getDirectory(projectDirectory),
location: { directory: projectDirectory },
})
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"), title: language.t("prompt.toast.worktreeCreateFailed.title"),
@@ -362,14 +361,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}) })
return undefined return undefined
}) })
if (!createdWorktree) return
if (!createdWorktree?.directory) {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: language.t("common.requestFailed"),
})
return
}
WorktreeState.pending(sdk().scope, createdWorktree.directory) WorktreeState.pending(sdk().scope, createdWorktree.directory)
sessionDirectory = createdWorktree.directory sessionDirectory = createdWorktree.directory
} }
@@ -379,10 +371,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
if (sessionDirectory !== projectDirectory) { if (sessionDirectory !== projectDirectory) {
client = sdk().createClient({
directory: sessionDirectory,
throwOnError: true,
})
serverSync().child(sessionDirectory) serverSync().child(sessionDirectory)
} }
@@ -487,8 +475,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
sessionID: session.id, sessionID: session.id,
id: eventID, id: eventID,
command: text, command: text,
agent,
model,
}) })
.catch((err) => { .catch((err) => {
showToast({ showToast({
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" 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" import { estimateSessionContextBreakdown } from "./session-context-breakdown"
const user = (id: string) => { 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" export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" 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" import { getSessionContext } from "./session-context-metrics"
const assistant = ( const assistant = (
@@ -1,4 +1,4 @@
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" import type { AssistantMessage, Message } from "@/types"
type Provider = { type Provider = {
id: string id: string
@@ -10,7 +10,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/session-ui/file" import { File } from "@opencode-ai/session-ui/file"
import { Markdown } from "@opencode-ai/session-ui/markdown" import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view" 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 { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
@@ -128,11 +128,7 @@ export const SettingsGeneral: Component = () => {
const [shells] = createResource( const [shells] = createResource(
async () => { async () => {
const sdk = serverSdk() // TODO: Restore executable shell discovery; V2 shell.list only lists shell processes.
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[] return [] as ShellOption[]
}, },
{ initialValue: [] as ShellOption[] }, { initialValue: [] as ShellOption[] },
@@ -331,6 +327,7 @@ export const SettingsGeneral: Component = () => {
> >
<Select <Select
data-action="settings-shell" data-action="settings-shell"
disabled
options={shellOptions()} options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption} current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
value={(o) => o.id} value={(o) => o.id}
@@ -338,7 +335,8 @@ export const SettingsGeneral: Component = () => {
onSelect={(option) => { onSelect={(option) => {
if (!option) return if (!option) return
if (option.value === currentShell()) return if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value }) // TODO: Restore config writes when the V2 client exposes a config API.
// void serverSync().updateConfig({ shell: option.value })
}} }}
variant="secondary" variant="secondary"
size="small" size="small"
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js" import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider" import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
import { DialogCustomProvider } from "./dialog-custom-provider" import { DialogCustomProvider } from "./dialog-custom-provider"
@@ -39,7 +39,6 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync() const serverSync = useServerSync()
const providers = useProviders(() => undefined) const providers = useProviders(() => undefined)
const providerConnect = useProviderConnectController({ onBack: props.onBack }) const providerConnect = useProviderConnectController({ onBack: props.onBack })
@@ -84,8 +83,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
return language.t("settings.providers.tag.other") return language.t("settings.providers.tag.other")
} }
const canDisconnect = (item: ProviderItem) => const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -98,7 +96,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
} }
const disableProvider = async (providerID: string, name: string) => { const disableProvider = async (providerID: string, name: string) => {
if (protocol() !== "v1") return return
const before = serverSync().data.config.disabled_providers ?? [] const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID] const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next) serverSync().set("config", "disabled_providers", next)
@@ -121,17 +119,14 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
} }
const disconnect = async (providerID: string, name: string) => { const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSDK()
.client.auth.remove({ providerID })
.catch(() => undefined)
await disableProvider(providerID, name)
return
}
await serverSDK() await serverSDK()
.client.auth.remove({ providerID }) .api.integration.get({ integrationID: providerID })
.then(async () => { .then(async (integration) => {
await serverSDK().client.global.dispose() 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({ showToast({
variant: "success", variant: "success",
icon: "circle-check", icon: "circle-check",
@@ -221,7 +216,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
)} )}
</For> </For>
<Show when={protocol() === "v1"}> <Show when={false}>
<div <div
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3" class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
data-component="custom-provider-section" data-component="custom-provider-section"
@@ -10,7 +10,6 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action" import { useUpdaterAction } from "../updater-action"
import { import {
monoDefault, monoDefault,
@@ -91,7 +90,6 @@ export const SettingsGeneralV2: Component<{
const dialog = useDialog() const dialog = useDialog()
const settings = useSettings() const settings = useSettings()
const serverSync = useServerSync() const serverSync = useServerSync()
const serverSdk = useServerSDK()
const mobile = createMediaQuery("(max-width: 767px)") const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction() const updater = useUpdaterAction()
@@ -123,10 +121,7 @@ export const SettingsGeneralV2: Component<{
const [shells] = createResource( const [shells] = createResource(
async () => { async () => {
const sdk = serverSdk() // TODO: Restore executable shell discovery when the V2 client exposes it.
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data // return (await sdk.api.pty.shells()).data
return [] as ShellOption[] return [] as ShellOption[]
}, },
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Accessor, type Component, For, Show } from "solid-js" import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider" import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
import { DialogCustomProvider } from "../dialog-custom-provider" import { DialogCustomProvider } from "../dialog-custom-provider"
@@ -36,7 +36,6 @@ export const SettingsProvidersV2: Component<{
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync() const serverSync = useServerSync()
const providers = useProviders(props.directory) const providers = useProviders(props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack }) const providerConnect = useProviderConnectController({ onBack: props.onBack })
@@ -81,8 +80,7 @@ export const SettingsProvidersV2: Component<{
return language.t("settings.providers.tag.other") return language.t("settings.providers.tag.other")
} }
const canDisconnect = (item: ProviderItem) => const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -95,7 +93,7 @@ export const SettingsProvidersV2: Component<{
} }
const disableProvider = async (providerID: string, name: string) => { const disableProvider = async (providerID: string, name: string) => {
if (protocol() !== "v1") return return
const before = serverSync().data.config.disabled_providers ?? [] const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID] const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next) serverSync().set("config", "disabled_providers", next)
@@ -118,17 +116,17 @@ export const SettingsProvidersV2: Component<{
} }
const disconnect = async (providerID: string, name: string) => { const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) { const location = props.directory() ? { directory: props.directory() } : undefined
await serverSdk()
.client.auth.remove({ providerID })
.catch(() => undefined)
await disableProvider(providerID, name)
return
}
await serverSdk() await serverSdk()
.client.auth.remove({ providerID }) .api.integration.get({ integrationID: providerID, location })
.then(async () => { .then(async (integration) => {
await serverSdk().client.global.dispose() 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({ showToast({
variant: "success", variant: "success",
icon: "circle-check", icon: "circle-check",
@@ -224,7 +222,7 @@ export const SettingsProvidersV2: Component<{
)} )}
</For> </For>
<Show when={protocol() === "v1"}> <Show when={false}>
<div class="settings-v2-provider-row" data-component="custom-provider-section"> <div class="settings-v2-provider-row" data-component="custom-provider-section">
<div class="settings-v2-provider-lead"> <div class="settings-v2-provider-lead">
<ProviderIcon <ProviderIcon
@@ -5,7 +5,7 @@ import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" import { type Accessor, createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -16,7 +16,7 @@ import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useMcpToggle } from "@/context/mcp" import { useMcpToggle } from "@/context/mcp"
import { useServerProtocol } from "@/context/server-sdk" import { useSDK } from "@/context/sdk"
const pluginEmptyMessage = (value: string, file: string): JSXElement => { const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file) const parts = value.split(file)
@@ -251,6 +251,7 @@ function ServerStatusList(props: { state: ServerStatusState }) {
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) { export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync() const sync = useSync()
const sdk = useSDK()
const global = useGlobal() const global = useGlobal()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
@@ -258,7 +259,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const language = useLanguage() const language = useLanguage()
const navigate = useNavigate() const navigate = useNavigate()
const settings = useSettings() const settings = useSettings()
const protocol = useServerProtocol()
const fail = (err: unknown) => { const fail = (err: unknown) => {
showToast({ showToast({
@@ -279,9 +279,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
dialogRun += 1 dialogRun += 1
}) })
const sortedServers = createMemo(() => { const sortedServers = createMemo(() => {
const list = settings.general.newLayoutDesigns() const list = global.servers.list()
? global.servers.list()
: global.servers.list().filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
return listServersByHealth(list, server.key, global.servers.health) return listServersByHealth(list, server.key, global.servers.health)
}) })
const toggleMcp = useMcpToggle() const toggleMcp = useMcpToggle()
@@ -291,9 +289,11 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync().data.lsp ?? []) const lspItems = createMemo(() => sync().data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length) const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() => const [pluginList] = createResource(
(sync().data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])), () => (props.shown() ? sdk().directory : undefined),
(directory) => sdk().api.plugin.list({ location: { directory } }).then((result) => result.data),
) )
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
const pluginCount = createMemo(() => plugins().length) const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json")) const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
@@ -322,7 +322,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
{lspCount() > 0 ? `${lspCount()} ` : ""} {lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")} {language.t("status.popover.tab.lsp")}
</Tabs.Trigger> </Tabs.Trigger>
<Show when={protocol() === "v1"}> <Show when={true}>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular"> <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""} {pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")} {language.t("status.popover.tab.plugins")}
@@ -487,7 +487,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</div> </div>
</Tabs.Content> </Tabs.Content>
<Show when={protocol() === "v1"}> <Show when={true}>
<Tabs.Content value="plugins"> <Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
@@ -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" import type { McpServer } from "@opencode-ai/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) { export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
+2 -61
View File
@@ -11,7 +11,6 @@ import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { terminalFontFamily, useSettings } from "@/context/settings" import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal" import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
@@ -175,15 +174,8 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings() const settings = useSettings()
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
const connection = useServerSDK()().server
const directory = sdk().directory const directory = sdk().directory
const url = sdk().url const url = sdk().url
const auth = connection.http
const username = auth?.username ?? "opencode"
const password = auth?.password ?? ""
const authToken = connection.type === "http" ? connection.authToken : false
const sameOrigin = new URL(url, location.href).origin === location.origin
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, [ const [local, others] = splitProps(props, [
"pty", "pty",
@@ -241,16 +233,6 @@ export const Terminal = (props: TerminalProps) => {
} }
const pushSize = async (cols: number, rows: number) => { 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() return sdk()
.api.pty.update({ .api.pty.update({
ptyID: id, ptyID: id,
@@ -533,15 +515,6 @@ export const Terminal = (props: TerminalProps) => {
} }
const gone = async () => { 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() return sdk()
.api.pty.get({ ptyID: id, location: { directory } }) .api.pty.get({ ptyID: id, location: { directory } })
.then((result) => result.data.status === "exited") .then((result) => result.data.status === "exited")
@@ -553,33 +526,8 @@ export const Terminal = (props: TerminalProps) => {
} }
const connectToken = async () => { const connectToken = async () => {
if ((await sdk().protocol) === "v1") { // TODO: Add PTY tickets when the V2 client exposes a connect-token API.
const result = await sdk() return undefined
.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)
} }
const retry = (err: unknown) => { const retry = (err: unknown) => {
@@ -609,23 +557,16 @@ export const Terminal = (props: TerminalProps) => {
fail(err) fail(err)
return undefined return undefined
}) })
const protocol = await sdk().protocol
// if (protocol === "v2" && !ticket) return
if (once.value) return if (once.value) return
if (disposed) return if (disposed) return
const socket = new WebSocket( const socket = new WebSocket(
terminalWebSocketURL({ terminalWebSocketURL({
protocol,
url, url,
id, id,
directory, directory,
cursor: seek, cursor: seek,
ticket, ticket,
sameOrigin,
username,
password,
authToken,
}), }),
) )
socket.binaryType = "arraybuffer" socket.binaryType = "arraybuffer"
@@ -8,7 +8,7 @@ import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" 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 { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover" import { TabPreviewPopover } from "./titlebar-tab-popover"
import "./titlebar-tab-nav.css" import "./titlebar-tab-nav.css"
@@ -19,7 +19,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order" import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
import type { Session } from "@opencode-ai/sdk/v2" import type { Session } from "@/types"
function SessionTabSlot(props: { function SessionTabSlot(props: {
tab: SessionTab tab: SessionTab
+3 -11
View File
@@ -1,5 +1,5 @@
import { Binary } from "@opencode-ai/core/util/binary" 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 { createMemo } from "solid-js"
import { produce, reconcile, type SetStoreFunction } from "solid-js/store" import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk" import type { createServerSdkContext } from "./server-sdk"
@@ -26,7 +26,6 @@ export const createDirSyncContext = (
serverSync: ReturnType<typeof createServerSyncContextInner>, serverSync: ReturnType<typeof createServerSyncContextInner>,
serverSDK: ReturnType<typeof createServerSdkContext>, serverSDK: ReturnType<typeof createServerSdkContext>,
) => { ) => {
const client = serverSDK.createClient({ directory, throwOnError: true })
const current = createMemo(() => serverSync.child(directory, { mcp: true })) const current = createMemo(() => serverSync.child(directory, { mcp: true }))
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const data = new Proxy({} as State, { const data = new Proxy({} as State, {
@@ -134,15 +133,8 @@ export const createDirSyncContext = (
}, },
more: createMemo(() => current()[0].session.length >= current()[0].limit), more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => { archive: async (sessionID: string) => {
if ((await serverSDK.protocol) !== "v1") return // TODO: Restore archiving when the V2 client exposes a session archive API.
await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } }) void sessionID
current()[1](
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (session) => session.id)
if (match.found) draft.splice(match.index, 1)
}),
)
}, },
}, },
mcp: { mcp: {
+12 -5
View File
@@ -81,8 +81,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
normalizeDir: path.normalizeDir, normalizeDir: path.normalizeDir,
list: (dir) => list: (dir) =>
sdk() sdk()
.client.file.list({ path: dir }) .api.file.list({ path: dir, location: { directory: scope() } })
.then((x) => x.data ?? []), .then((x) =>
x.data.map((entry) => ({
...entry,
name: entry.path.split("/").at(-1) ?? entry.path,
absolute: `${scope()}/${entry.path}`,
ignored: false,
})),
),
onError: (message) => { onError: (message) => {
showToast({ showToast({
variant: "error", variant: "error",
@@ -181,10 +188,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setLoading(file) setLoading(file)
const promise = sdk() const promise = sdk()
.client.file.read({ path: file }) .api.file.read({ path: file, location: { directory } })
.then((x) => { .then((data) => {
if (scope() !== directory) return if (scope() !== directory) return
const content = x.data const content = { type: "text" as const, content: new TextDecoder().decode(data) }
setLoaded(file, content) setLoaded(file, content)
if (!content) return 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_ENTRIES = 40
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024 const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
+1 -1
View File
@@ -1,5 +1,5 @@
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import type { FileNode } from "@opencode-ai/sdk/v2" import type { FileNode } from "@/types"
type DirectoryState = { type DirectoryState = {
expanded: boolean expanded: boolean
+1 -1
View File
@@ -1,4 +1,4 @@
import type { FileContent } from "@opencode-ai/sdk/v2" import type { FileContent } from "@/types"
export type FileSelection = { export type FileSelection = {
startLine: number startLine: number
+1 -1
View File
@@ -1,4 +1,4 @@
import type { FileNode } from "@opencode-ai/sdk/v2" import type { FileNode } from "@/types"
type WatcherEvent = { type WatcherEvent = {
type: string type: string
@@ -1,11 +1,8 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query" import { QueryClient } from "@tanstack/solid-query"
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise" import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { import {
bootstrapDirectory,
loadAgentsQuery, loadAgentsQuery,
loadCommands, loadCommands,
loadPathQuery, loadPathQuery,
@@ -13,139 +10,19 @@ import {
loadProvidersQuery, loadProvidersQuery,
loadReferencesQuery, loadReferencesQuery,
} from "./bootstrap" } from "./bootstrap"
import type { State, VcsCache } from "./types"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
type ProjectApi = ServerApi["project"] type ProjectApi = ServerApi["project"]
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
const api = {
agent: { list: async () => ({ location: {}, data: [] }) },
provider: { list: async () => ({ location: {}, data: [] }) },
model: {
list: async () => ({ location: {}, data: [] }),
default: async () => ({ location: {}, data: null }),
},
permission: { request: { list: async () => ({ location: {}, data: [] }) } },
project: {
list: async () => [],
current: async () => ({ id: "project", directory: "/project" }),
},
question: { request: { list: async () => ({ location: {}, data: [] }) } },
reference: { list: async () => ({ location: {}, data: [] }) },
vcs: { get: async () => ({ location: {}, data: {} }) },
} as unknown as ServerApi
function directoryState() {
return createStore<State>({
status: "loading",
agent: [],
command: [],
reference: [],
project: "",
projectMeta: undefined,
icon: undefined,
provider_ready: true,
provider,
config: {},
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
session: [],
sessionTotal: 0,
session_status: {},
session_working(id: string) {
return this.session_status[id]?.type !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp_ready: true,
mcp: {},
mcp_resource: {},
lsp_ready: true,
lsp: [],
vcs: undefined,
limit: 5,
message: {},
session_message: {},
part: {},
part_text_accum_delta: {},
})
}
describe("bootstrapDirectory", () => {
test("uses 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", () => { describe("query keys", () => {
test("partitions identical directories by server scope", () => { test("partitions identical directories by server scope", () => {
const client = {} as Parameters<typeof loadPathQuery>[2]
const api = {} as CatalogApi const api = {} as CatalogApi
const location = {} as ServerApi["location"]
const remote = "https://debian.example" as typeof ServerScope.local const remote = "https://debian.example" as typeof ServerScope.local
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"]) expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["local", "/repo", "path"])
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"]) expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
}) })
@@ -180,6 +57,21 @@ describe("query keys", () => {
expect(result.connected).toEqual(["openai"]) expect(result.connected).toEqual(["openai"])
}) })
test("loads current location metadata", async () => {
const calls: unknown[] = []
const api = {
get: async (input: unknown) => {
calls.push(input)
return { directory: "/repo/subpath", project: { id: "project", directory: "/repo" } }
},
} as ServerApi["location"]
const result = await new QueryClient().fetchQuery(loadPathQuery(ServerScope.local, "/repo/subpath", api))
expect(calls).toEqual([{ location: { directory: "/repo/subpath" } }])
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
})
test("loads agents from the current location-scoped endpoint", async () => { test("loads agents from the current location-scoped endpoint", async () => {
const calls: unknown[] = [] const calls: unknown[] = []
const api = { const api = {
+39 -114
View File
@@ -1,6 +1,5 @@
import type { import type {
Config, Config,
OpencodeClient,
Path, Path,
PermissionRequest, PermissionRequest,
Project, Project,
@@ -8,7 +7,7 @@ import type {
QuestionRequest, QuestionRequest,
ReferenceInfo, ReferenceInfo,
Session, Session,
} from "@opencode-ai/sdk/v2/client" } from "@/types"
import type { import type {
AgentListInput, AgentListInput,
AgentListOutput, AgentListOutput,
@@ -16,6 +15,8 @@ import type {
CommandInfo, CommandInfo,
CommandListInput, CommandListInput,
CommandListOutput, CommandListOutput,
LocationGetInput,
LocationGetOutput,
ProjectCurrentInput, ProjectCurrentInput,
ProjectCurrentOutput, ProjectCurrentOutput,
ProjectListOutput, ProjectListOutput,
@@ -43,7 +44,6 @@ import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope" import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import { normalizeSessionInfo } from "@/utils/session" import { normalizeSessionInfo } from "@/utils/session"
import type { ServerProtocol } from "@/utils/server-protocol"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
type GlobalStore = { type GlobalStore = {
@@ -105,16 +105,18 @@ function showErrors(input: {
}) })
} }
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) => export const loadGlobalConfigQuery = (scope: ServerScope) =>
queryOptions({ queryOptions({
queryKey: [scope, "config"], queryKey: [scope, "config"],
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)), // TODO: Restore config loading when the V2 client exposes a config API.
queryFn: async (): Promise<Config> => ({}),
}) })
type ProjectApi = { type ProjectApi = {
readonly list: () => Promise<ProjectListOutput> readonly list: () => Promise<ProjectListOutput>
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput> readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
} }
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
type McpApi = ServerApi["mcp"] type McpApi = ServerApi["mcp"]
type PermissionApi = ServerApi["permission"] type PermissionApi = ServerApi["permission"]
@@ -138,9 +140,7 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
}) })
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
serverAPI: CatalogApi & { readonly project: ProjectApi }
protocol?: Promise<ServerProtocol>
scope: ServerScope scope: ServerScope
requestFailedTitle: string requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
@@ -149,12 +149,12 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient queryClient: QueryClient
}) { }) {
const slow = [ const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)), () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
() => () =>
input.queryClient.fetchQuery( input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI, 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 input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)) .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
@@ -219,17 +219,11 @@ export const loadProvidersQuery = (
scope: ServerScope, scope: ServerScope,
directory: string | null, directory: string | null,
sdk: CatalogApi, sdk: CatalogApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) => ) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "providers"], queryKey: [scope, directory, "providers"],
queryFn: () => queryFn: () =>
retry(async () => { retry(async () => {
if ((await protocol) === "v1" && legacy) {
const result = await legacy.provider.list()
return normalizeProviderList(result.data!)
}
const location = directory ? { location: { directory } } : undefined const location = directory ? { location: { directory } } : undefined
const [providers, models, defaultModel] = await Promise.all([ const [providers, models, defaultModel] = await Promise.all([
sdk.provider.list(location), sdk.provider.list(location),
@@ -256,71 +250,45 @@ export const loadAgentsQuery = (
scope: ServerScope, scope: ServerScope,
directory: string, directory: string,
sdk: AgentListApi, sdk: AgentListApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) => ) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "agents"], queryKey: [scope, directory, "agents"],
queryFn: () => queryFn: () =>
retry(async () => { retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? [])
return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))
}),
}) })
export const loadCommands = ( export const loadCommands = (
directory: string, directory: string,
api: CommandListApi, api: CommandListApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
): Promise<CommandInfo[]> => ): Promise<CommandInfo[]> =>
retry(async () => { retry(() => api.list({ location: { directory } }).then((result) => result.data))
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)
})
export const loadPathQuery = ( export const loadPathQuery = (
scope: ServerScope, scope: ServerScope,
directory: string | null, directory: string | null,
sdk: OpencodeClient, api: LocationApi,
protocol?: Promise<ServerProtocol>,
) => ) =>
queryOptions<Path>({ queryOptions<Path>({
queryKey: [scope, directory, "path"], queryKey: [scope, directory, "path"],
queryFn: async () => { queryFn: () =>
if ((await protocol) !== "v1") api.get(directory ? { location: { directory } } : undefined).then((location) => ({
return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" } state: "",
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!)) config: "",
}, worktree: location.project.directory,
directory: location.directory,
home: "",
})),
}) })
export const loadReferencesQuery = ( export const loadReferencesQuery = (
scope: ServerScope, scope: ServerScope,
directory: string, directory: string,
api: ReferenceListApi, api: ReferenceListApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) => ) =>
queryOptions<ReferenceInfo[]>({ queryOptions<ReferenceInfo[]>({
queryKey: [scope, directory, "references"] as const, queryKey: [scope, directory, "references"] as const,
queryFn: () => queryFn: () =>
retry(async () => { retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []),
if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? []
return api.list({ location: { directory } }).then((result) => result.data)
}).catch(() => []),
placeholderData: [], placeholderData: [],
}) })
@@ -328,7 +296,6 @@ export async function bootstrapDirectory(input: {
directory: string directory: string
scope: ServerScope scope: ServerScope
mcp: boolean mcp: boolean
sdk: OpencodeClient
api: CatalogApi & { api: CatalogApi & {
readonly agent: AgentListApi readonly agent: AgentListApi
readonly command: CommandListApi readonly command: CommandListApi
@@ -339,6 +306,7 @@ export async function bootstrapDirectory(input: {
readonly reference: ReferenceListApi readonly reference: ReferenceListApi
readonly session: SessionApi readonly session: SessionApi
readonly vcs: VcsApi readonly vcs: VcsApi
readonly location: LocationApi
} }
store: Store<State> store: Store<State>
setStore: SetStoreFunction<State> setStore: SetStoreFunction<State>
@@ -353,7 +321,6 @@ export async function bootstrapDirectory(input: {
} }
queryClient: QueryClient queryClient: QueryClient
session?: ServerSession session?: ServerSession
protocol?: Promise<ServerProtocol>
}) { }) {
const loading = input.store.status !== "complete" const loading = input.store.status !== "complete"
const seededProject = projectID(input.directory, input.global.project) const seededProject = projectID(input.directory, input.global.project)
@@ -373,37 +340,8 @@ export async function bootstrapDirectory(input: {
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
() => () =>
input.queryClient 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)), .then((data) => input.setStore("agent", data)),
() =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
() =>
retry(() =>
(async () => {
if ((await input.protocol) !== "v1") return
const x = await input.sdk.session.status()
if (!input.session) {
input.setStore("session_status", x.data!)
return
}
const statuses = x.data ?? {}
input.session.set(
"session_status",
produce((draft) => {
for (const sessionID of Object.keys(draft)) {
if (statuses[sessionID]) continue
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
}
}),
)
for (const [sessionID, status] of Object.entries(statuses)) {
input.session.set("session_status", sessionID, reconcile(status))
}
await Promise.all(
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
)
})(),
),
!seededProject && !seededProject &&
(() => (() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) => retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
@@ -412,37 +350,26 @@ export async function bootstrapDirectory(input: {
!seededPath && !seededPath &&
(() => (() =>
input.queryClient input.queryClient
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol)) .ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location))
.then((data) => { .then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project) const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next) if (next) input.setStore("project", next)
})), })),
() =>
retry(async () => {
if ((await input.protocol) !== "v1") return
return input.sdk.vcs.get().then((result) => {
const next = { branch: result.data?.branch, default_branch: result.data?.default_branch }
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
})
}),
input.mcp && input.mcp &&
(() => (() =>
loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) => loadCommands(input.directory, input.api.command).then((commands) =>
input.setStore("command", commands), input.setStore("command", commands),
)), )),
() => () =>
input.queryClient.fetchQuery( input.queryClient.fetchQuery(
loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol), loadReferencesQuery(input.scope, input.directory, input.api.reference),
), ),
() => () =>
retry(() => retry(() =>
(async () => { input.api.permission.request
if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? [] .list({ location: { directory: input.directory } })
return input.api.permission.request .then((result) => result.data.map(normalizePermissionRequest))
.list({ location: { directory: input.directory } }) .then((permissions) => {
.then((result) => result.data.map(normalizePermissionRequest))
})().then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID) const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession( const grouped = groupBySession(
permissions.filter((permission) => !!permission.id && !!permission.sessionID), permissions.filter((permission) => !!permission.id && !!permission.sessionID),
@@ -473,12 +400,10 @@ export async function bootstrapDirectory(input: {
), ),
() => () =>
retry(() => retry(() =>
(async () => { input.api.question.request
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? [] .list({ location: { directory: input.directory } })
return input.api.question.request .then((result) => result.data)
.list({ location: { directory: input.directory } }) .then((questions) => {
.then((result) => result.data)
})().then((questions) => {
const ids = questions.map((question) => question.sessionID) const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession( const grouped = groupBySession(
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[], questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
@@ -511,16 +436,16 @@ export async function bootstrapDirectory(input: {
input.mcp && input.mcp &&
(() => (() =>
input.queryClient.fetchQuery( 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.mcp &&
(() => (() =>
input.queryClient.fetchQuery( 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 input.queryClient
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol)) .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api))
.catch((err) => { .catch((err) => {
const project = getFilename(input.directory) const project = getFilename(input.directory)
showToast({ showToast({
@@ -1,7 +1,7 @@
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client" import type { VcsInfo } from "@/types"
import { import {
DIR_IDLE_TTL_MS, DIR_IDLE_TTL_MS,
MAX_DIR_STORES, MAX_DIR_STORES,
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" 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 { createStore } from "solid-js/store"
import type { State } from "./types" import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
@@ -9,7 +9,7 @@ import type {
Session, Session,
SessionStatus, SessionStatus,
Todo, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { SessionV2Info } from "@opencode-ai/sdk/v2/client" import type { SessionV2Info } from "@/types"
import { import {
applyHomeSessionEvent, applyHomeSessionEvent,
appendHomeSessionEvent, appendHomeSessionEvent,
@@ -33,7 +33,7 @@ describe("Home V2 session index", () => {
const calls: unknown[] = [] const calls: unknown[] = []
const result = await loadHomeSessionIndex(async (input) => { const result = await loadHomeSessionIndex(async (input) => {
calls.push(input) calls.push(input)
return { data: { data: [session({ id: "root" })], cursor: {} } } return { data: [session({ id: "root" })], cursor: {} }
}) })
expect(result.sessions).toHaveLength(1) expect(result.sessions).toHaveLength(1)
@@ -48,15 +48,13 @@ describe("Home V2 session index", () => {
calls.push({ input, signal: options.signal }) calls.push({ input, signal: options.signal })
if (!("cursor" in input)) { if (!("cursor" in input)) {
return { return {
data: { data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session({ id: `page-1-${index}` }),
session({ id: `page-1-${index}` }), ),
), cursor: { next: "next-page" },
cursor: { next: "next-page" },
},
} }
} }
return { data: { data: [session({ id: "page-2" })], cursor: {} } } return { data: [session({ id: "page-2" })], cursor: {} }
}, },
0, 0,
controller.signal, controller.signal,
@@ -145,10 +143,8 @@ describe("Home V2 session index", () => {
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current") expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
}) })
test("refetches after reconnect, disposal, and session moves", () => { test("refetches after reconnect", () => {
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false }) expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true }) expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
}) })
}) })
@@ -1,4 +1,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 type { QueryClient } from "@tanstack/solid-query"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
@@ -21,13 +21,11 @@ export type HomeSessionIndex = {
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
type HomeSessionPage = { data?: V2SessionListResponse }
export async function loadHomeSessionIndex( export async function loadHomeSessionIndex(
list: ( list: (
input: { limit: number; order: "desc"; cursor?: string }, input: { limit: number; order: "desc"; cursor?: string },
options: { signal?: AbortSignal }, options: { signal?: AbortSignal },
) => Promise<HomeSessionPage>, ) => Promise<V2SessionListResponse>,
eventSequence = 0, eventSequence = 0,
signal?: AbortSignal, signal?: AbortSignal,
) { ) {
@@ -43,7 +41,7 @@ export async function loadHomeSessionIndex(
}, },
{ signal }, { signal },
) )
const page = response.data! const page = response
data.push(...page.data) data.push(...page.data)
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next) if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
return { sessions: parseHomeSessionIndex(data), eventSequence } return { sessions: parseHomeSessionIndex(data), eventSequence }
@@ -75,10 +73,7 @@ export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, ev
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) { export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
if (event === "server.connected") return { connected: true, refetch: connected } if (event === "server.connected") return { connected: true, refetch: connected }
return { return { connected, refetch: false }
connected,
refetch: event === "global.disposed" || event === "session.next.moved",
}
} }
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) { export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
@@ -145,7 +140,7 @@ export function retainHomeSessions(sessions: Session[], limit: number, now: numb
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) { export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
const info = event.properties.info const info = event.properties.info
const index = sessions.findIndex((session) => session.id === info.id) const index = sessions.findIndex((session) => session.id === info.id)
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") { if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
if (index === -1) return sessions if (index === -1) return sessions
return sessions.toSpliced(index, 1) return sessions.toSpliced(index, 1)
} }
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" 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 type { FileDiffInfo } from "@opencode-ai/client/promise"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" 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 { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } 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 type { SessionApi } from "@opencode-ai/client/promise"
import { normalizeSessionInfo } from "@/utils/session" import { normalizeSessionInfo } from "@/utils/session"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) { export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
const result = await input.api.list({ const result = await input.api.list({
@@ -16,16 +15,6 @@ export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; d
} as const } as const
} }
export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
try {
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
return { data: result.data, limit: input.limit, limited: true } as const
} catch {
const result = await input.client.session.list({ directory: input.directory, roots: true })
return { data: result.data, limit: input.limit, limited: false } as const
}
}
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) { export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
if (!input.limited) return input.count if (!input.limited) return input.count
if (input.count < input.limit) return input.count if (input.count < input.limit) return input.count
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" 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" import { trimSessions } from "./session-trim"
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) => 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 { cmp } from "./utils"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types" import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
@@ -12,7 +12,7 @@ import type {
SessionStatus, SessionStatus,
Todo, Todo,
VcsInfo, VcsInfo,
} from "@opencode-ai/sdk/v2/client" } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise" import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
+2 -15
View File
@@ -5,7 +5,7 @@ import type {
PermissionRequest, PermissionRequest,
ProviderListOutput, ProviderListOutput,
} from "@opencode-ai/client/promise" } 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 type { Project as CurrentProject } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
@@ -58,20 +58,7 @@ export function normalizeProviderList(
defaultModel?: ModelDefaultOutput["data"], defaultModel?: ModelDefaultOutput["data"],
): NormalizedProviderListResponse { ): NormalizedProviderListResponse {
if (!Array.isArray(providers)) { if (!Array.isArray(providers)) {
return { return providers
...providers,
all: new Map(
providers.all.map((provider) => [
provider.id,
{
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"),
),
},
]),
),
}
} }
const all = new Map<string, Provider>() const all = new Map<string, Provider>()
+3 -12
View File
@@ -7,7 +7,7 @@ import { useServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2" import type { Project } from "@/types"
import { normalizeProjectInfo } from "./global-sync/utils" import { normalizeProjectInfo } from "./global-sync/utils"
import { Persist, persisted, removePersisted } from "@/utils/persist" import { Persist, persisted, removePersisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
@@ -572,17 +572,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const projectID = project.id const projectID = project.id
void (async () => { void (async () => {
const sdk = serverSdk() // TODO: Restore project color updates when the V2 client exposes a project update API.
if ((await sdk.protocol) !== "v1") return void projectID
return sdk.client.project
.update({ projectID, directory: worktree, icon: { color } })
.then((response) => response.data)
.then((result) => {
if (!result) return
serverSync().set("project", (items) =>
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
)
})
})().catch(() => { })().catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
}) })
+1 -1
View File
@@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2" import type { EventSessionError } from "@/types"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound" import { playSoundById } from "@/utils/sound"
import { useGlobal } from "./global" import { useGlobal } from "./global"
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" 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 { base64Encode } from "@opencode-ai/core/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond" import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
+1 -6
View File
@@ -1,7 +1,7 @@
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" 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 { Persist, persisted } from "@/utils/persist"
import type { ServerSDK } from "@/context/server-sdk" import type { ServerSDK } from "@/context/server-sdk"
import type { ServerSync } from "./server-sync" import type { ServerSync } from "./server-sync"
@@ -212,7 +212,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
) )
function enableConfiguredDirectory(directory: string) { function enableConfiguredDirectory(directory: string) {
if (input.sdk.protocolKind() !== "v1") return
if (meta.disposed || !ready()) return if (meta.disposed || !ready()) return
const [childStore] = input.sync.child(directory) const [childStore] = input.sync.child(directory)
if (childStore.config.permission !== "allow") return if (childStore.config.permission !== "allow") return
@@ -250,7 +249,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
sessionID: request.sessionID, sessionID: request.sessionID,
requestID: request.permissionID, requestID: request.permissionID,
reply: request.response, reply: request.response,
location: request.directory ? { directory: request.directory } : undefined,
}) })
.catch(() => { .catch(() => {
responded.delete(request.permissionID) responded.delete(request.permissionID)
@@ -258,9 +256,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
} }
const list = async (directory: string) => { const list = async (directory: string) => {
if ((await input.sdk.protocol) === "v1") {
return (await input.sdk.client.permission.list({ directory })).data ?? []
}
return input.sdk.api.permission.request return input.sdk.api.permission.request
.list({ location: { directory } }) .list({ location: { directory } })
.then((result) => result.data.map(normalizePermissionRequest)) .then((result) => result.data.map(normalizePermissionRequest))
+1 -1
View File
@@ -1,5 +1,5 @@
import { checksum } from "@opencode-ai/core/util/encode" 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 { batch, createMemo, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store" import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
+6 -4
View File
@@ -1,10 +1,9 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, createMemo } from "solid-js" import { type Accessor, createMemo } from "solid-js"
import { type ServerSDK, useServerSDK } from "./server-sdk" import { type DirectorySDK, useServerSDK } from "./server-sdk"
export type { DirectorySDK } from "./server-sdk"
export type DirectorySDK = ReturnType<ServerSDK["ensureDirSdkContext"]> const context = createSimpleContext({
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK", name: "SDK",
// Resolves the directory-scoped SDK reactively from the (possibly changing) server. // Resolves the directory-scoped SDK reactively from the (possibly changing) server.
init: (props: { directory: string | Accessor<string> }) => { init: (props: { directory: string | Accessor<string> }) => {
@@ -15,3 +14,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
}) })
}, },
}) })
export const useSDK: () => Accessor<DirectorySDK> = context.use
export const SDKProvider = context.provider
+25 -150
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@opencode-ai/sdk/v2/client" import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
describe("resumeStreamAfterPageShow", () => { describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => { test("restarts a stream only after a back-forward cache restore", () => {
@@ -16,7 +15,7 @@ describe("resumeStreamAfterPageShow", () => {
}) })
describe("adaptServerEvent", () => { describe("adaptServerEvent", () => {
test("preserves V2 events while adapting permission requests for existing consumers", () => { test("preserves current events while adapting permission requests for existing consumers", () => {
const current = { const current = {
id: "evt_1", id: "evt_1",
created: 1, created: 1,
@@ -25,6 +24,7 @@ describe("adaptServerEvent", () => {
} as OpenCodeEvent } as OpenCodeEvent
expect(adaptServerEvent(current)).toMatchObject({ expect(adaptServerEvent(current)).toMatchObject({
id: "evt_1",
type: "permission.asked", type: "permission.asked",
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] }, properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
current, current,
@@ -32,165 +32,40 @@ describe("adaptServerEvent", () => {
}) })
}) })
describe("coalesceServerEvents", () => { describe("current event buffering", () => {
const delta = (value: string, field = "text", partID = "part") => ({ const delta = (id: string, value: string, ordinal = 0) => ({
directory: "/repo", directory: "/repo",
payload: { payload: adaptServerEvent({
type: "message.part.delta", id,
properties: { messageID: "msg", partID, field, delta: value }, created: 1,
} as Event, type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
} as OpenCodeEvent),
}) })
test("merges adjacent deltas for the same field", () => { test("merges adjacent text deltas for the same message and ordinal", () => {
const first = delta("hello ") const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
const second = delta("world")
first.payload.id = "first"
second.payload.id = "second"
const result = coalesceServerEvents([first, second])
expect(result).toHaveLength(1)
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
})
test("merges adjacent current text deltas", () => {
const current = (id: string, value: string) =>
adaptServerEvent({
id,
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
} as OpenCodeEvent)
const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "hello ") },
{ directory: "/repo", payload: current("evt_2", "world") },
])
expect(result).toHaveLength(1) expect(result).toHaveLength(1)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } }) expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
}) })
test("preserves event boundaries and distinct fields", () => { test("preserves boundaries between distinct delta streams", () => {
const status = { const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
directory: "/repo",
payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event,
}
const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")])
expect(result.map((event) => event.payload.type)).toEqual([ expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual([
"message.part.delta", "evt_1",
"message.part.delta", "evt_2",
"session.status", "evt_3",
"message.part.delta",
]) ])
}) })
test("preserves event ID order across interleaved deltas", () => { test("preserves current event order when enqueuing", () => {
const first = delta("a") const events: Parameters<typeof enqueueServerEvent>[0] = []
const other = delta("b", "text", "other") ;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
const last = delta("c")
first.payload.id = "1"
other.payload.id = "2"
last.payload.id = "3"
const result = coalesceServerEvents([first, other, last]) expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
})
})
describe("enqueueServerEvent", () => {
const partUpdated = (text: string) =>
({
type: "message.part.updated",
properties: {
sessionID: "session",
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
},
}) as Event
test("preserves part updates across message remove and re-add barriers", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old"))
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
enqueue({
type: "message.updated",
properties: {
sessionID: "session",
info: {
id: "message",
sessionID: "session",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
},
},
} as Event)
enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([
"message.part.updated",
"message.removed",
"message.updated",
"message.part.updated",
])
})
test("preserves deltas after a replacement snapshot", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("a"))
enqueue(partUpdated("ab"))
enqueue({
type: "message.part.delta",
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
} as Event)
const result = coalesceServerEvents(events)
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
})
test("preserves updates after session deletion", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old"))
enqueue({
type: "session.deleted",
properties: { sessionID: "session", info: { id: "session" } },
} as Event)
enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([
"message.part.updated",
"session.deleted",
"message.part.updated",
])
})
test("does not coalesce edge-triggered session statuses", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (status: "retry" | "busy") =>
enqueueServerEvent(events, {
directory: "/repo",
payload: {
type: "session.status",
properties: {
sessionID: "session",
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
},
} as Event,
})
enqueue("retry")
enqueue("busy")
expect(events).toHaveLength(2)
}) })
}) })
+21 -123
View File
@@ -1,24 +1,22 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise" 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 { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, createResource, onCleanup, onMount } from "solid-js" import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server" import { createApiForServer, type ServerApi } from "@/utils/server"
import { useLanguage } from "./language" import { useLanguage } from "./language"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server" import { ServerConnection, useServer } from "./server"
import { createRefCountMap } from "@/utils/refcount" import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global" import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
const isAbortError = (error: unknown) => const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true 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 QueuedServerEvent = { directory: string; payload: ServerEvent }
type CurrentDelta = Extract< type CurrentDelta = Extract<
OpenCodeEvent, OpenCodeEvent,
@@ -41,29 +39,14 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
event.data.source?.type === "tool" event.data.source?.type === "tool"
? { messageID: event.data.source.messageID, callID: event.data.source.callID } ? { messageID: event.data.source.messageID, callID: event.data.source.callID }
: undefined, : undefined,
}, } satisfies PermissionRequest,
current: event, current: event,
} as ServerEvent }
} }
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
} }
const coalescedKey = (event: QueuedServerEvent) => {
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
if (event.payload.type === "message.part.updated") {
const part = event.payload.properties.part
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
}
return undefined
}
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) { export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
const key = coalescedKey(event)
const previous = queue[queue.length - 1]
if (key && previous && coalescedKey(previous) === key) {
queue[queue.length - 1] = event
return false
}
queue.push(event) queue.push(event)
return true return true
} }
@@ -99,33 +82,7 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
output.push(event) output.push(event)
return return
} }
if (event.payload.type !== "message.part.delta") { output.push(event)
output.push(event)
return
}
const props = event.payload.properties
const previous = output[output.length - 1]
if (
!previous ||
previous.payload.type !== "message.part.delta" ||
previous.directory !== event.directory ||
previous.payload.properties.messageID !== props.messageID ||
previous.payload.properties.partID !== props.partID ||
previous.payload.properties.field !== props.field
) {
output.push({
directory: event.directory,
payload: { ...event.payload, properties: { ...props } },
})
return
}
output[output.length - 1] = {
directory: event.directory,
payload: {
...event.payload,
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
},
}
}) })
return output return output
} }
@@ -160,20 +117,13 @@ type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]:
type ServerSDKBase = { type ServerSDKBase = {
server: ServerConnection.Any server: ServerConnection.Any
scope: ServerScope scope: ServerScope
protocol: Promise<ServerProtocol>
protocolKind: Accessor<ServerProtocol | undefined>
url: string url: string
client: ReturnType<typeof createSdkForServer> api: ServerApi
api: CompatibleApi
currentApi: ServerApi
event: { event: {
on: ServerEventEmitter["on"] on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"] listen: ServerEventEmitter["listen"]
start: () => Promise<void> | undefined start: () => Promise<void> | undefined
} }
createClient: (
opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">,
) => ReturnType<typeof createSdkForServer>
} }
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
@@ -192,16 +142,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
})() })()
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch }) const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
const 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<{ const emitter = createGlobalEmitter<{
[key: string]: ServerEvent [key: string]: ServerEvent
}>() }>()
@@ -264,18 +204,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
} }
abort.signal.addEventListener("abort", onAbort) abort.signal.addEventListener("abort", onAbort)
try { try {
const kind = await protocol const events = eventApi.event.subscribe({ signal: attempt.signal })
const events =
kind === "v1"
? (await eventSdk.global.event({ signal: attempt.signal })).stream
: eventApi.event.subscribe({ signal: attempt.signal })
let yielded = Date.now() let yielded = Date.now()
for await (const event of events) { for await (const event of events) {
streamErrorLogged = false streamErrorLogged = false
const legacy = "payload" in event const directory = event.location?.directory ?? "global"
if (legacy && event.payload.type === "sync") continue const payload = adaptServerEvent(event)
const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global")
const payload = legacy ? (event.payload as Event) : adaptServerEvent(event)
if (enqueueServerEvent(queue, { directory, payload })) schedule() if (enqueueServerEvent(queue, { directory, payload })) schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue if (Date.now() - yielded < STREAM_YIELD_MS) continue
@@ -325,42 +259,18 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
flush() flush()
}) })
const sdk = createSdkForServer({ const api = createApiForServer({ server: server.http, fetch: platform.fetch })
server: server.http,
fetch: platform.fetch,
throwOnError: true,
})
const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch })
const legacy = (directory?: string) =>
createSdkForServer({
server: server.http,
fetch: platform.fetch,
throwOnError: true,
directory,
})
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
return { return {
server, server,
scope, scope,
protocol,
protocolKind,
url: server.http.url, url: server.http.url,
client: sdk,
api, api,
currentApi,
event: { event: {
on: emitter.on.bind(emitter), on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter), listen: emitter.listen.bind(emitter),
start, start,
}, },
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
return createSdkForServer({
server: server.http,
fetch: platform.fetch,
...opts,
})
},
} }
} }
@@ -392,21 +302,19 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
}, },
}) })
export function useServerProtocol() {
const serverSDK = useServerSDK()
return createMemo(() => serverSDK().protocolKind())
}
type SDKEventMap = { type SDKEventMap = {
[key in Event["type"]]: Extract<ServerEvent, { type: key }> [key in Event["type"]]: Extract<ServerEvent, { type: key }>
} }
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { export type DirectorySDK = {
const client = serverSDK.createClient({ scope: ServerScope
directory, directory: string
throwOnError: true, api: ServerApi
}) event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
readonly url: string
}
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
const emitter = createGlobalEmitter<SDKEventMap>() const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.event.on(directory, (event) => { const unsub = serverSDK.event.on(directory, (event) => {
@@ -416,21 +324,11 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
return { return {
scope: serverSDK.scope, scope: serverSDK.scope,
protocol: serverSDK.protocol,
directory, directory,
client, api: serverSDK.api,
api: createCompatibleApi({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
}),
event: emitter, event: emitter,
get url() { get url() {
return serverSDK.url return serverSDK.url
}, },
createClient(opts: Parameters<typeof serverSDK.createClient>[0]) {
return serverSDK.createClient(opts)
},
} }
} }
+181 -94
View File
@@ -1,7 +1,14 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { retry } from "@opencode-ai/core/util/retry" import type { retry } from "@opencode-ai/core/util/retry"
import type { OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise" import type {
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" OpenCodeEvent,
SessionApi,
SessionInfo,
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import type { Message, Part, Session } from "@/types"
import { createServerSession } from "./server-session" import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
@@ -21,19 +28,130 @@ const session = (id: string, parentID?: string): Session => ({
type UserMessage = Extract<Message, { role: "user" }> type UserMessage = Extract<Message, { role: "user" }>
type AssistantMessage = Extract<Message, { role: "assistant" }> type AssistantMessage = Extract<Message, { role: "assistant" }>
type TextPart = Extract<Part, { type: "text" }> type TextPart = Extract<Part, { type: "text" }>
type CurrentToolObject = Extract<SessionMessageAssistantTool["state"], { status: "running" }>["input"]
type MessageResponse = { type MessageResponse = {
data: { info: Message; parts: Part[] }[] data: { info: Message; parts: Part[] }[]
response: { headers: Headers } response: { headers: Headers }
} }
type SingleMessageResponse = { data: MessageResponse["data"][number] } type SingleMessageResponse = { data: MessageResponse["data"][number] }
function sessionInfo(value: Session): SessionInfo {
return {
id: value.id,
parentID: value.parentID,
projectID: value.projectID,
cost: value.cost ?? 0,
tokens: value.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: value.time,
title: value.title,
location: { directory: value.directory, workspaceID: value.workspaceID },
subpath: value.path,
}
}
function currentMessages(data: MessageResponse["data"]): SessionMessageInfo[] {
return data.flatMap((item): SessionMessageInfo[] => {
if (item.info.role === "user") {
return [
{
id: `${item.info.id}:agent`,
type: "agent-switched",
agent: item.info.agent,
time: item.info.time,
},
{
id: `${item.info.id}:model`,
type: "model-switched",
model: {
id: item.info.model.modelID,
providerID: item.info.model.providerID,
variant: item.info.model.variant,
},
time: item.info.time,
},
{
id: item.info.id,
type: "user",
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
time: item.info.time,
},
]
}
return [{
id: item.info.id,
type: "assistant",
agent: item.info.agent,
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
content: item.parts.flatMap((part): SessionMessageAssistant["content"] => {
if (part.type === "text") return [{ type: "text", text: part.text }]
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
time: part.time ? { created: part.time.start, completed: part.time.end } : undefined,
},
]
if (part.type !== "tool") return []
const state: SessionMessageAssistantTool["state"] = (() => {
if (part.state.status === "pending") return { status: "streaming" as const, input: JSON.stringify(part.state.input) }
if (part.state.status === "running")
return {
status: "running" as const,
input: part.state.input as CurrentToolObject,
metadata: (part.state.metadata ?? {}) as CurrentToolObject,
}
if (part.state.status === "error")
return {
status: "error" as const,
input: part.state.input as CurrentToolObject,
error: { type: "tool_error", message: part.state.error },
metadata: part.state.metadata as CurrentToolObject | undefined,
}
return {
status: "completed" as const,
input: part.state.input as CurrentToolObject,
content: [{ type: "text" as const, text: part.state.output }],
metadata: part.state.metadata as CurrentToolObject,
}
})()
return [
{
id: part.id,
type: "tool" as const,
name: part.tool,
state,
time: {
created: part.state.status === "pending" ? item.info.time.created : part.state.time.start,
ran: part.state.status === "pending" ? undefined : part.state.time.start,
completed:
part.state.status === "completed" || part.state.status === "error" ? part.state.time.end : undefined,
},
},
]
}),
time: item.info.time,
cost: item.info.cost,
tokens: item.info.tokens,
finish: item.info.finish as "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | undefined,
}]
})
}
function currentPage(value: MessageResponse) {
return {
data: currentMessages(value.data).toReversed(),
cursor: { next: value.response.headers.get("x-next-cursor") ?? undefined },
}
}
const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({ const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({
id, id,
sessionID: "child", sessionID: "child",
role: "user", role: "user",
time: { created: 1 }, time: { created: 1 },
agent: "build", agent: "build",
model: { providerID: "provider", modelID: "model" }, model: { providerID: "provider", modelID: "model", variant: undefined },
...input, ...input,
}) })
@@ -45,21 +163,24 @@ const assistantMessage = (id: string, parentID: string, input: Partial<Assistant
parentID, parentID,
modelID: "model", modelID: "model",
providerID: "provider", providerID: "provider",
variant: undefined,
mode: "build", mode: "build",
agent: "build", agent: "build",
path: { cwd: "/repo", root: "/repo" }, path: { cwd: "", root: "" },
cost: 0, cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
error: undefined,
finish: undefined,
...input, ...input,
}) })
const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({ const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
id: "part",
sessionID: "child", sessionID: "child",
messageID, messageID,
type: "text", type: "text",
text: "text", text: "text",
...input, ...input,
id: `${messageID}:text:${input.id === "pending" ? 1 : 0}`,
}) })
const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({ const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({
@@ -73,19 +194,27 @@ const deferredResponse = () => Promise.withResolvers<MessageResponse>()
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) { function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
let index = 0 let index = 0
const pages = responses.map((value) =>
value instanceof Promise ? value.then(currentPage) : Promise.resolve(currentPage(value)),
)
const requests: unknown[] = [] const requests: unknown[] = []
const waiting = new Map<number, () => void>() const waiting = new Map<number, () => void>()
const client = { const client = {
session: { session: {
get: async () => ({ data: session("child", "root") }), get: async () => sessionInfo(session("child", "root")),
messages: (input: unknown) => { message: async () => {
throw new Error("Unexpected single message request")
},
},
message: {
list: async (input: unknown) => {
requests.push(input) requests.push(input)
waiting.get(requests.length)?.() waiting.get(requests.length)?.()
waiting.delete(requests.length) waiting.delete(requests.length)
return responses[index++] return pages[index++]!
}, },
}, },
} as unknown as OpencodeClient } as unknown as { session: SessionApi; message: MessageApi }
return Object.assign(client, { return Object.assign(client, {
requests, requests,
requested(count: number) { requested(count: number) {
@@ -106,19 +235,22 @@ function rootMessageClient(
const rootWaiting = new Map<number, () => void>() const rootWaiting = new Map<number, () => void>()
const client = { const client = {
session: { session: {
get: async () => ({ data: session("child", "root") }), get: async () => sessionInfo(session("child", "root")),
messages: (input: unknown) => { message: async (input: unknown) => {
requests.push(input)
return pages[pageIndex++]
},
message: (input: unknown) => {
rootRequests.push(input) rootRequests.push(input)
rootWaiting.get(rootRequests.length)?.() rootWaiting.get(rootRequests.length)?.()
rootWaiting.delete(rootRequests.length) rootWaiting.delete(rootRequests.length)
return roots[rootIndex++] const value = await roots[rootIndex++]!
return currentMessages([value.data]).find((message) => message.id === value.data.info.id)!
}, },
}, },
} as unknown as OpencodeClient message: {
list: async (input: unknown) => {
requests.push(input)
return currentPage(await pages[pageIndex++]!)
},
},
} as unknown as { session: SessionApi; message: MessageApi }
return Object.assign(client, { return Object.assign(client, {
requests, requests,
rootRequests, rootRequests,
@@ -148,16 +280,19 @@ function setup(sessions: Record<string, Session>) {
get: async (input: unknown) => { get: async (input: unknown) => {
get.push(input) get.push(input)
const id = (input as { sessionID: string }).sessionID const id = (input as { sessionID: string }).sessionID
return { data: sessions[id] } return sessionInfo(sessions[id]!)
}, },
messages: async (input: unknown) => { message: async () => {
messages.push(input) throw new Error("Unexpected single message request")
return response()
}, },
diff: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
}, },
} as unknown as OpencodeClient message: {
list: async (input: unknown) => {
messages.push(input)
return currentPage(response())
},
},
} as unknown as { session: SessionApi; message: MessageApi }
return { get, messages, store: createServerSession(client) } return { get, messages, store: createServerSession(client) }
} }
@@ -229,7 +364,7 @@ describe("server session", () => {
await ctx.store.sync("root") await ctx.store.sync("root")
expect(ctx.get).toEqual([{ sessionID: "root" }]) expect(ctx.get).toEqual([{ sessionID: "root" }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }]) expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
expect(ctx.store.data.message.root).toEqual([]) expect(ctx.store.data.message.root).toEqual([])
}) })
@@ -244,20 +379,13 @@ describe("server session", () => {
content: [{ type: "text", text: "hi" }], content: [{ type: "text", text: "hi" }],
time: { created: 2, completed: 3 }, time: { created: 2, completed: 3 },
} }
const client = {
session: {
messages: () => {
throw new Error("legacy message endpoint called")
},
},
} as unknown as OpencodeClient
const messageApi = { const messageApi = {
list: async (input: unknown) => { list: async (input: unknown) => {
requests.push(input) requests.push(input)
return { data: [assistant, user], cursor: { previous: null, next: null } } return { data: [assistant, user], cursor: { previous: null, next: null } }
}, },
} as unknown as MessageApi } as unknown as MessageApi
const store = createServerSession(client, {} as SessionApi, messageApi) const store = createServerSession({} as SessionApi, messageApi)
store.remember(session("root")) store.remember(session("root"))
await store.sync("root") await store.sync("root")
@@ -292,7 +420,7 @@ describe("server session", () => {
return pages.shift()! return pages.shift()!
}, },
} as unknown as MessageApi } as unknown as MessageApi
const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi) const store = createServerSession({} as SessionApi, messageApi)
store.remember(session("root")) store.remember(session("root"))
await store.sync("root") await store.sync("root")
@@ -308,50 +436,14 @@ describe("server session", () => {
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"]) expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
}) })
test("indexes V1 messages for the current timeline projection", async () => { // V2 messages are ordered projections and do not expose V1 assistant parent IDs.
const user = userMessage("message-1", { sessionID: "root" }) describe.skip("V1 assistant parent projections", () => {
const assistant = assistantMessage("message-2", user.id, { sessionID: "root" })
const client = messageClient(
response([
{ info: user, parts: [textPart(user.id, { sessionID: "root" })] },
{ info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] },
]),
)
const messageApi = {
list: () => {
throw new Error("current message endpoint called")
},
} as unknown as MessageApi
const store = createServerSession(client, {} as SessionApi, messageApi, {
protocol: Promise.resolve("v1"),
})
store.remember(session("root"))
await store.sync("root")
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.data.session_message.root).toMatchObject([
{ id: user.id, type: "user", text: "text" },
{ id: assistant.id, type: "assistant" },
])
const next = userMessage("message-3", { sessionID: "root" })
store.apply({ type: "message.updated", properties: { info: next } })
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id])
store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } })
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
})
test("backfills an assistant-only initial page through its user root", async () => { test("backfills an assistant-only initial page through its user root", async () => {
const user = userMessage("message-1") const user = userMessage("message-1")
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient( const client = rootMessageClient(
[ [
response( response(assistants.map((info) => ({ info, parts: [] }))),
assistants.map((info) => ({ info, parts: [] })),
"older",
),
], ],
[singleResponse(user)], [singleResponse(user)],
) )
@@ -359,16 +451,16 @@ describe("server session", () => {
await store.sync("child") await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }]) expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants]) expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(true) expect(store.history.more("child")).toBe(false)
}) })
test("keeps assistant history when its deleted parent cannot be backfilled", async () => { test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
const missing = Promise.withResolvers<SingleMessageResponse>() const missing = Promise.withResolvers<SingleMessageResponse>()
const assistant = assistantMessage("message-2", "message-missing") const assistant = assistantMessage("message-2", "message-missing")
const client = rootMessageClient([response([{ info: assistant, parts: [] }], "older")], [missing.promise]) const client = rootMessageClient([response([{ info: assistant, parts: [] }])], [missing.promise])
const store = createServerSession(client) const store = createServerSession(client)
const loading = store.sync("child") const loading = store.sync("child")
await client.rootRequested(1) await client.rootRequested(1)
@@ -378,7 +470,7 @@ describe("server session", () => {
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
expect(store.data.message.child).toEqual([assistant]) expect(store.data.message.child).toEqual([assistant])
expect(store.history.more("child")).toBe(true) expect(store.history.more("child")).toBe(false)
}) })
test("drops a cached parent when a forced refresh confirms it was deleted", async () => { test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
@@ -392,7 +484,7 @@ describe("server session", () => {
{ info: parent, parts: [part] }, { info: parent, parts: [part] },
{ info: assistant, parts: [] }, { info: assistant, parts: [] },
]), ]),
response([{ info: assistant, parts: [] }], "older"), response([{ info: assistant, parts: [] }]),
], ],
[missing.promise], [missing.promise],
) )
@@ -414,10 +506,7 @@ describe("server session", () => {
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient( const client = rootMessageClient(
[ [
response( response(assistants.map((info) => ({ info, parts: [] }))),
assistants.map((info) => ({ info, parts: [] })),
"older",
),
], ],
[singleResponse(user)], [singleResponse(user)],
) )
@@ -439,10 +528,7 @@ describe("server session", () => {
const client = rootMessageClient( const client = rootMessageClient(
[ [
response([{ info: unrelated, parts: [] }]), response([{ info: unrelated, parts: [] }]),
response( response(assistants.map((info) => ({ info, parts: [] }))),
assistants.map((info) => ({ info, parts: [] })),
"older",
),
], ],
[singleResponse(user)], [singleResponse(user)],
) )
@@ -461,7 +547,7 @@ describe("server session", () => {
const cached = userMessage("message-3", { time: { created: 3 } }) const cached = userMessage("message-3", { time: { created: 3 } })
const assistant = assistantMessage("message-4", user.id) const assistant = assistantMessage("message-4", user.id)
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }], "older")], [response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }])],
[singleResponse(user)], [singleResponse(user)],
) )
const store = createServerSession(client) const store = createServerSession(client)
@@ -479,7 +565,7 @@ describe("server session", () => {
const freshPart = { ...stalePart, text: "fresh" } const freshPart = { ...stalePart, text: "fresh" }
const assistant = assistantMessage("message-2", stale.id) const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }], "older")], [response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }])],
[singleResponse(fresh, [freshPart])], [singleResponse(fresh, [freshPart])],
) )
const store = createServerSession(client) const store = createServerSession(client)
@@ -500,7 +586,7 @@ describe("server session", () => {
const pending = textPart(stale.id, { id: "pending", text: "pending" }) const pending = textPart(stale.id, { id: "pending", text: "pending" })
const assistant = assistantMessage("message-2", stale.id) const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }], "older")], [response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])],
[singleResponse(fresh, [refreshed])], [singleResponse(fresh, [refreshed])],
) )
const store = createServerSession(client) const store = createServerSession(client)
@@ -523,7 +609,7 @@ describe("server session", () => {
const loading = store.sync("child") const loading = store.sync("child")
store.apply({ type: "message.updated", properties: { info: user } }) store.apply({ type: "message.updated", properties: { info: user } })
pending.resolve(response([{ info: assistant, parts: [] }], "older")) pending.resolve(response([{ info: assistant, parts: [] }]))
await loading await loading
expect(client.rootRequests).toEqual([]) expect(client.rootRequests).toEqual([])
@@ -539,7 +625,6 @@ describe("server session", () => {
[ [
response( response(
assistants.map((info) => ({ info, parts: [] })), assistants.map((info) => ({ info, parts: [] })),
"older",
), ),
], ],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
@@ -563,7 +648,7 @@ describe("server session", () => {
const assistant = assistantMessage("message-2", user.id) const assistant = assistantMessage("message-2", user.id)
const live = { ...assistant, cost: 1 } const live = { ...assistant, cost: 1 }
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: assistant, parts: [] }], "older")], [response([{ info: assistant, parts: [] }])],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
) )
const store = createServerSession(client, { retry: retryImmediately }) const store = createServerSession(client, { retry: retryImmediately })
@@ -583,7 +668,7 @@ describe("server session", () => {
const assistant = assistantMessage("message-2", user.id) const assistant = assistantMessage("message-2", user.id)
const live = userMessage("message-4", { time: { created: 4 } }) const live = userMessage("message-4", { time: { created: 4 } })
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: assistant, parts: [] }], "older")], [response([{ info: assistant, parts: [] }])],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
) )
const store = createServerSession(client, { retry: retryImmediately }) const store = createServerSession(client, { retry: retryImmediately })
@@ -604,7 +689,7 @@ describe("server session", () => {
const stale = textPart(assistant.id, { text: "stale" }) const stale = textPart(assistant.id, { text: "stale" })
const live = { ...stale, text: "live" } const live = { ...stale, text: "live" }
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: assistant, parts: [stale] }], "older")], [response([{ info: assistant, parts: [stale] }])],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
) )
const store = createServerSession(client, { retry: retryImmediately }) const store = createServerSession(client, { retry: retryImmediately })
@@ -617,6 +702,7 @@ describe("server session", () => {
expect(store.data.part[assistant.id]).toEqual([live]) expect(store.data.part[assistant.id]).toEqual([live])
}) })
})
test("merges live events into the initial page", async () => { test("merges live events into the initial page", async () => {
const pending = deferredResponse() const pending = deferredResponse()
@@ -1416,6 +1502,7 @@ describe("server session", () => {
await store.history.loadMore("child") await store.history.loadMore("child")
guard.active = false
expect(store.data.message.child).toEqual([older, latest]) expect(store.data.message.child).toEqual([older, latest])
}) })
+74 -112
View File
@@ -3,19 +3,16 @@ import { retry } from "@opencode-ai/core/util/retry"
import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise" import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { import type {
Message, Message,
OpencodeClient,
Part, Part,
PermissionRequest, PermissionRequest,
QuestionRequest, QuestionRequest,
Session, Session,
SessionStatus, SessionStatus,
Todo, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { batch } from "solid-js" import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { message as cleanMessage } from "@/utils/diffs"
import { sessionNotFoundError } from "@/utils/server-errors"
import { rootSession } from "@/utils/session-route" import { rootSession } from "@/utils/session-route"
import { normalizeSessionInfo } from "@/utils/session" import { normalizeSessionInfo } from "@/utils/session"
import { normalizeSessionMessages } from "@/utils/session-message" import { normalizeSessionMessages } from "@/utils/session-message"
@@ -33,6 +30,29 @@ const historyMessagePageSize = 200
const sessionInfoLimit = 2_048 const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set() const emptyIDs: ReadonlySet<string> = new Set()
function projectMessageSource(message: Message): SessionMessageInfo[] {
if (message.role === "user") {
return [
{ id: `${message.id}:agent`, type: "agent-switched", agent: message.agent, time: message.time },
{
id: `${message.id}:model`,
type: "model-switched",
model: { id: message.model.modelID, providerID: message.model.providerID, variant: message.model.variant },
time: message.time,
},
{ id: message.id, type: "user", text: "", time: message.time },
]
}
return [{
id: message.id,
type: "assistant",
agent: message.agent ?? message.mode,
model: { id: message.modelID, providerID: message.providerID, variant: message.variant },
content: [],
time: message.time,
}]
}
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
const boundary = source.find( const boundary = source.find(
(message) => (message) =>
@@ -61,30 +81,6 @@ type MessagePage = {
complete: boolean complete: boolean
} }
function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
return items
.slice()
.sort((a, b) => cmp(a.info.id, b.info.id))
.map((item) => {
if (item.info.role === "user") {
return {
id: item.info.id,
type: "user" as const,
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
time: item.info.time,
}
}
return {
id: item.info.id,
type: "assistant" as const,
agent: item.info.agent ?? item.info.mode,
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
content: [],
time: item.info.time,
}
})
}
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries. // Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
type MessageLoadState = { type MessageLoadState = {
touchedMessages: Set<string> touchedMessages: Set<string>
@@ -183,16 +179,18 @@ function reconcileFetched<T extends { id: string }>(
return [...result.values()].sort((a, b) => cmp(a.id, b.id)) return [...result.values()].sort((a, b) => cmp(a.id, b.id))
} }
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> } type ServerSessionOptions = { retry?: typeof retry }
type ServerSessionApis = { session: SessionApi; message: MessageApi }
export function createServerSession( export function createServerSession(
client: OpencodeClient, api: SessionApi | ServerSessionApis,
sessionApiOrOptions?: SessionApi | ServerSessionOptions, messageApiOrOptions?: MessageApi | ServerSessionOptions,
messageApi?: MessageApi,
currentOptions?: ServerSessionOptions, currentOptions?: ServerSessionOptions,
) { ) {
const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined const bundled = "session" in api
const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined) const sessionApi = bundled ? api.session : api
const messageApi = bundled ? api.message : (messageApiOrOptions as MessageApi)
const options = bundled ? (messageApiOrOptions as ServerSessionOptions | undefined) : currentOptions
const [data, setData] = createStore({ const [data, setData] = createStore({
info: {} as Record<string, Session | undefined>, info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>, session_status: {} as Record<string, SessionStatus>,
@@ -247,13 +245,13 @@ export function createServerSession(
at: {} as Record<string, number | undefined>, at: {} as Record<string, number | undefined>,
}) })
const indexLegacyMessage = (message: Message) => { const indexProjectedMessage = (message: Message) => {
const current = data.session_message[message.sessionID] ?? [] const current = data.session_message[message.sessionID] ?? []
if (current.some((item) => item.id === message.id)) return if (current.some((item) => item.id === message.id)) return
setData( setData(
"session_message", "session_message",
message.sessionID, message.sessionID,
reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]), reconcile([...current, ...projectMessageSource(message)]),
) )
} }
@@ -307,12 +305,7 @@ export function createServerSession(
const pending = requests.get(sessionID) const pending = requests.get(sessionID)
if (pending) return pending if (pending) return pending
const active = generation(sessionID) const active = generation(sessionID)
const request = sessionApi const request = sessionApi.get({ sessionID }).then(normalizeSessionInfo)
? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
: client.session.get({ sessionID }).then((result) => {
if (!result.data) throw sessionNotFoundError(sessionID)
return result.data
})
const resolved = request.then((result) => { const resolved = request.then((result) => {
if (generations.get(sessionID) !== active) return result if (generations.get(sessionID) !== active) return result
return remember(result) return remember(result)
@@ -538,72 +531,43 @@ export function createServerSession(
) )
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
if (messageApi && (await options?.protocol) !== "v1") { const request = (cursor?: string) =>
const request = (cursor?: string) => (options?.retry ?? retry)(() => {
(options?.retry ?? retry)(() => { onAttempt?.()
onAttempt?.() return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" }) })
}) const first = await request(before)
const first = await request(before) const pages = [first]
const pages = [first] while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) {
while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) { const response = await request(pages.at(-1)!.cursor.next ?? undefined)
const response = await request(pages.at(-1)!.cursor.next ?? undefined) pages.push(response)
pages.push(response) if (!response.data.length) break
if (!response.data.length) break
}
const response = pages.at(-1)!
const source = pages.flatMap((page) => page.data).toReversed()
const normalized = normalizeSessionMessages(sessionID, source)
return {
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
source,
sourceMode: before ? ("older" as const) : ("latest" as const),
projectSource: true,
cursor: response.cursor.next ?? undefined,
complete: response.data.length === 0,
}
} }
const response = await (options?.retry ?? retry)(() => { const response = pages.at(-1)!
onAttempt?.() const source = pages.flatMap((page) => page.data).toReversed()
return client.session.messages({ sessionID, limit, before }) const normalized = normalizeSessionMessages(sessionID, source)
})
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
return { return {
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)), session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
part: items.map((item) => ({ part: [...normalized.parts.entries()]
id: item.info.id, .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), .sort((a, b) => cmp(a.id, b.id)),
})), source,
source: legacyMessageSource(items),
sourceMode: before ? ("older" as const) : ("latest" as const), sourceMode: before ? ("older" as const) : ("latest" as const),
cursor: response.response.headers.get("x-next-cursor") ?? undefined, projectSource: true,
complete: !response.response.headers.get("x-next-cursor"), cursor: response.cursor.next ?? undefined,
complete: !response.cursor.next,
} }
} }
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => { const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
if (sessionApi && (await options?.protocol) !== "v1") {
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return sessionApi.message({ sessionID, messageID })
})
const normalized = normalizeSessionMessages(sessionID, [response])
const message = normalized.messages[0]
if (!message) throw new Error(`Message not found: ${messageID}`)
return { message, parts: normalized.parts.get(messageID) ?? [] }
}
const response = await (options?.retry ?? retry)(() => { const response = await (options?.retry ?? retry)(() => {
onAttempt?.() onAttempt?.()
return client.session.message({ sessionID, messageID }) return sessionApi.message({ sessionID, messageID })
}) })
if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`) const normalized = normalizeSessionMessages(sessionID, [response])
return { const message = normalized.messages[0]
message: cleanMessage(response.data.info), if (!message) throw new Error(`Message not found: ${messageID}`)
parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), return { message, parts: normalized.parts.get(messageID) ?? [] }
}
} }
const replaceMessages = (sessionID: string, messages: Message[]) => { const replaceMessages = (sessionID: string, messages: Message[]) => {
@@ -683,7 +647,14 @@ export function createServerSession(
? (() => { ? (() => {
const incoming = new Map(page.source.map((message) => [message.id, message])) const incoming = new Map(page.source.map((message) => [message.id, message]))
const existing = data.session_message[sessionID] ?? [] 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])) const live = new Map(existing.map((message) => [message.id, message]))
return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map( return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
(message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message), (message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),
@@ -1029,8 +1000,8 @@ export function createServerSession(
return return
} }
case "message.updated": { case "message.updated": {
const info = cleanMessage((event.properties as { info: Message }).info) const info = (event.properties as { info: Message }).info
indexLegacyMessage(info) indexProjectedMessage(info)
const load = messageLoads.get(info.sessionID) const load = messageLoads.get(info.sessionID)
load?.touchedMessages.add(info.id) load?.touchedMessages.add(info.id)
load?.removedMessages.delete(info.id) load?.removedMessages.delete(info.id)
@@ -1381,17 +1352,8 @@ export function createServerSession(
async todo(sessionID: string, request?: { force?: boolean }) { async todo(sessionID: string, request?: { force?: boolean }) {
touch(sessionID) touch(sessionID)
if (data.todo[sessionID] !== undefined && !request?.force) return if (data.todo[sessionID] !== undefined && !request?.force) return
if ((await options?.protocol) === "v2") { // TODO: Restore todos when the V2 client exposes a session todo API.
setData("todo", sessionID, []) setData("todo", sessionID, [])
return
}
return runInflight(inflightTodo, sessionID, () => {
const active = generation(sessionID)
return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => {
if (generations.get(sessionID) !== active) return
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
})
})
}, },
history: { history: {
more: (sessionID: string) => more: (sessionID: string) =>
+1 -2
View File
@@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { import type {
McpListInput, McpListInput,
McpResourceCatalogInput, McpResourceCatalogInput,
@@ -84,7 +83,7 @@ describe("active session query", () => {
}) })
test("does not overwrite statuses already written by events", () => { test("does not overwrite statuses already written by events", () => {
const session = createServerSession({} as OpencodeClient) const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"])
session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 }) session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 })
seedActiveSessionStatuses(session, { seedActiveSessionStatuses(session, {
+43 -92
View File
@@ -1,11 +1,10 @@
import type { import type {
Config, Config,
OpencodeClient,
Path, Path,
Project, Project,
ProviderAuthResponse, ProviderAuthResponse,
SessionStatus, SessionStatus,
} from "@opencode-ai/sdk/v2/client" } from "@/types"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
@@ -27,7 +26,7 @@ import {
} from "./global-sync/bootstrap" } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store" import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" 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 { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types" import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types"
@@ -59,6 +58,7 @@ import type {
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import { toggleMcp } from "./global-sync/mcp" import { toggleMcp } from "./global-sync/mcp"
import { createServerSession, type ServerSession } from "./server-session" import { createServerSession, type ServerSession } from "./server-session"
import { usePlatform } from "./platform"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -94,8 +94,6 @@ export const loadMcpQuery = (
scope: ServerScope, scope: ServerScope,
directory: string, directory: string,
api: McpListApi, api: McpListApi,
legacy?: OpencodeClient,
protocol?: Promise<"v1" | "v2">,
): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> => ): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> =>
queryOptions< queryOptions<
Record<string, McpServer["status"]>, Record<string, McpServer["status"]>,
@@ -105,7 +103,6 @@ export const loadMcpQuery = (
>({ >({
queryKey: [scope, directory, "mcp"] as const, queryKey: [scope, directory, "mcp"] as const,
queryFn: async () => { queryFn: async () => {
if ((await protocol) === "v1" && legacy) return (await legacy.mcp.status()).data ?? {}
return api return api
.list({ location: { directory } }) .list({ location: { directory } })
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status]))) .then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
@@ -116,8 +113,6 @@ export const loadMcpResourcesQuery = (
scope: ServerScope, scope: ServerScope,
directory: string, directory: string,
api: McpResourceApi, api: McpResourceApi,
legacy?: OpencodeClient,
protocol?: Promise<"v1" | "v2">,
): ApiQueryOptions<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> => ): ApiQueryOptions<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> =>
queryOptions< queryOptions<
Record<string, McpResource>, Record<string, McpResource>,
@@ -127,14 +122,6 @@ export const loadMcpResourcesQuery = (
>({ >({
queryKey: [scope, directory, "mcpResources"] as const, queryKey: [scope, directory, "mcpResources"] as const,
queryFn: async () => { 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 return api.resource
.catalog({ location: { directory } }) .catalog({ location: { directory } })
.then((result) => .then((result) =>
@@ -144,10 +131,11 @@ export const loadMcpResourcesQuery = (
placeholderData: {}, placeholderData: {},
}) })
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => export const loadLspQuery = (scope: ServerScope, directory: string) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "lsp"] as const, queryKey: [scope, directory, "lsp"] as const,
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), // TODO: Restore LSP status when the V2 client exposes an LSP API.
queryFn: async () => [],
}) })
export const loadActiveSessionsQuery = ( export const loadActiveSessionsQuery = (
@@ -178,25 +166,19 @@ export function seedActiveSessionStatuses(
function makeQueryOptionsApi( function makeQueryOptionsApi(
scope: ServerScope, scope: ServerScope,
serverSDK: () => OpencodeClient,
serverAPI: ServerApi, serverAPI: ServerApi,
sdkFor: (dir: PathKey) => OpencodeClient,
protocol: Promise<"v1" | "v2">,
) { ) {
return { return {
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()), globalConfig: () => loadGlobalConfigQuery(scope),
projects: () => loadProjectsQuery(scope, serverAPI.project), projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) => providers: (directory: PathKey | null) =>
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol), loadProvidersQuery(scope, directory, serverAPI),
path: (directory: PathKey | null) => path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
loadPathQuery(scope, directory, directory ? sdkFor(directory) : serverSDK(), protocol), agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol), references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
references: (directory: PathKey) => mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol), mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol), lsp: (directory: PathKey) => loadLspQuery(scope, directory),
mcpResources: (directory: PathKey) =>
loadMcpResourcesQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
} }
} }
@@ -204,36 +186,16 @@ export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
export function createServerSyncContextInner(serverSDK: ServerSDK) { export function createServerSyncContextInner(serverSDK: ServerSDK) {
const language = useLanguage() const language = useLanguage()
const platform = usePlatform()
const owner = getOwner() const owner = getOwner()
if (!owner) throw new Error("ServerSync must be created within owner") if (!owner) throw new Error("ServerSync must be created within owner")
const sdkCache = new Map<string, OpencodeClient>()
const booting = new Map<string, Promise<void>>() const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>() const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>() const sessionMeta = new Map<string, { limit: number }>()
const sdkFor = (directory: string) => { const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
const key = directoryKey(directory) const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
const cached = sdkCache.get(key)
if (cached) return cached
const sdk = serverSDK.createClient({
directory,
throwOnError: true,
})
sdkCache.set(key, sdk)
return sdk
}
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
protocol: serverSDK.protocol,
})
const queryOptionsApi = makeQueryOptionsApi(
serverSDK.scope,
() => serverSDK.client,
serverSDK.api,
sdkFor,
serverSDK.protocol,
)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
@@ -241,18 +203,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const activeSessionsQuery = useQuery(() => const activeSessionsQuery = useQuery(() =>
loadActiveSessionsQuery(serverSDK.scope, { loadActiveSessionsQuery(serverSDK.scope, {
active: async () => { 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() const active = await serverSDK.api.session.active()
seedActiveSessionStatuses(session, active) seedActiveSessionStatuses(session, active)
for (const sessionID of Object.keys(active)) { for (const sessionID of Object.keys(active)) {
@@ -321,9 +271,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryKey: [serverSDK.scope, "bootstrap"], queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => { queryFn: async () => {
await bootstrapGlobal({ await bootstrapGlobal({
serverSDK: serverSDK.client,
serverAPI: serverSDK.api, serverAPI: serverSDK.api,
protocol: serverSDK.protocol,
scope: serverSDK.scope, scope: serverSDK.scope,
requestFailedTitle: language.t("common.requestFailed"), requestFailedTitle: language.t("common.requestFailed"),
translate: language.t, translate: language.t,
@@ -363,7 +311,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
void bootstrapInstance(directory) void bootstrapInstance(directory)
}, },
onMcp: (directory, setStore) => { onMcp: (directory, setStore) => {
void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol) void loadCommands(directory, serverSDK.api.command)
.then((commands) => setStore("command", commands)) .then((commands) => setStore("command", commands))
.catch((err) => { .catch((err) => {
showToast({ showToast({
@@ -377,7 +325,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const key = directoryKey(directory) const key = directoryKey(directory)
queue.clear(key) queue.clear(key)
sessionMeta.delete(key) sessionMeta.delete(key)
sdkCache.delete(key)
clearProviderRev(serverSDK.scope, key) clearProviderRev(serverSDK.scope, key)
}, },
translate: language.t, translate: language.t,
@@ -416,12 +363,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
.fetchQuery({ .fetchQuery({
...queryOptionsApi.sessions(key), ...queryOptionsApi.sessions(key),
queryFn: () => queryFn: () =>
serverSDK.protocol loadRootSessions({ api: serverSDK.api.session, directory, limit })
.then((protocol) =>
protocol === "v1"
? loadRootSessionsV1({ client: sdkFor(directory), directory, limit })
: loadRootSessions({ api: serverSDK.api.session, directory, limit }),
)
.then((x) => { .then((x) => {
const nonArchived = (x.data ?? []) const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id) .filter((s) => !!s?.id)
@@ -479,7 +421,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const child = children.ensureChild(directory) const child = children.ensureChild(directory)
const cache = children.vcsCache.get(key) const cache = children.vcsCache.get(key)
if (!cache) return if (!cache) return
const sdk = sdkFor(directory)
await bootstrapDirectory({ await bootstrapDirectory({
directory, directory,
scope: serverSDK.scope, scope: serverSDK.scope,
@@ -490,7 +431,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
project: globalStore.project, project: globalStore.project,
provider: globalStore.provider, provider: globalStore.provider,
}, },
sdk,
api: serverSDK.api, api: serverSDK.api,
store: child[0], store: child[0],
setStore: child[1], setStore: child[1],
@@ -499,7 +439,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
translate: language.t, translate: language.t,
queryClient, queryClient,
session, session,
protocol: serverSDK.protocol,
}) })
}) })
@@ -538,7 +477,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (event.current) session.applyV2(event.current) if (event.current) session.applyV2(event.current)
session.apply(event) session.apply(event)
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") { if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
homeSessions.apply(event) if ("info" in event.properties) homeSessions.apply(event as Parameters<typeof homeSessions.apply>[0])
} }
homeSessions.refresh(event.type) homeSessions.refresh(event.type)
if (eventType === "integration.connection.updated") void refreshProviders() if (eventType === "integration.connection.updated") void refreshProviders()
@@ -658,7 +597,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
} }
const updateConfigMutation = useMutation(() => ({ const updateConfigMutation = useMutation(() => ({
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }), mutationFn: async (config: Config) => {
// TODO: Restore config updates when the V2 client exposes a config API.
// await serverSDK.api.config.update({ config })
throw new Error(`Config updates are unavailable: ${Object.keys(config).length} fields were not saved`)
},
onSuccess: () => { onSuccess: () => {
bootstrap.refetch() bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers // Invalidate all provider queries so newly configured custom providers
@@ -692,27 +635,35 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
mcp: { mcp: {
toggle: async (directory: string, name: string) => { toggle: async (directory: string, name: string) => {
const key = directoryKey(directory) const key = directoryKey(directory)
const sdk = sdkFor(key)
const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status
if (!status) return if (!status) return
await toggleMcp({ await toggleMcp({
status, status,
connect: async () => { connect: async () => {
if ((await serverSDK.protocol) === "v1") {
await sdk.mcp.connect({ name })
return
}
await serverSDK.api.mcp.connect({ server: name, location: { directory: key } }) await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
}, },
disconnect: async () => { disconnect: async () => {
if ((await serverSDK.protocol) === "v1") {
await sdk.mcp.disconnect({ name })
return
}
await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } }) await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
}, },
authenticate: async () => { 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 () => { refresh: async () => {
await queryClient.refetchQueries(queryOptionsApi.mcp(key)) await queryClient.refetchQueries(queryOptionsApi.mcp(key))
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" 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" import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
type Text = Extract<Part, { type: "text" }> type Text = Extract<Part, { type: "text" }>
+1 -1
View File
@@ -2,7 +2,7 @@ import { Binary } from "@opencode-ai/core/util/binary"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useServerSync } from "./server-sync" import { useServerSync } from "./server-sync"
import { useSDK } from "./sdk" 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"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
+1 -1
View File
@@ -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 { createSimpleContext } from "@opencode-ai/ui/context"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"

Some files were not shown because too many files have changed in this diff Show More