Compare commits

..
169 changed files with 1806 additions and 3319 deletions
+32 -32
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -37,7 +37,7 @@
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -96,7 +96,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.18.5",
"version": "1.18.4",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -144,7 +144,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -158,7 +158,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -194,7 +194,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -221,7 +221,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -243,7 +243,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -267,7 +267,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -287,7 +287,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.18.5",
"version": "1.18.4",
"bin": {
"opencode": "./bin/opencode",
},
@@ -381,7 +381,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -435,7 +435,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -449,7 +449,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"effect": "catalog:",
},
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -493,7 +493,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -509,7 +509,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -540,7 +540,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -559,7 +559,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.18.5",
"version": "1.18.4",
"bin": {
"opencode": "./bin/opencode",
},
@@ -690,7 +690,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -766,7 +766,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -781,7 +781,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -796,10 +796,10 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -841,7 +841,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -854,7 +854,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -888,7 +888,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -907,7 +907,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -949,7 +949,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -976,7 +976,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1027,7 +1027,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.5",
"version": "1.18.4",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -6030,7 +6030,7 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="],
"@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
@@ -6050,7 +6050,7 @@
"@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="],
"@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-2suqkFAUeT2YgppKqP14s0C+GE3TR3uNKOOD8vWEiAg=",
"aarch64-linux": "sha256-cJ/XYTodYmgs9ZFQhHQbfZPqxjIMmEvz062OsX6/6p4=",
"aarch64-darwin": "sha256-tKyqHeI6r1aHEk4vBS39+eA6wh9KcIwsa2ctcktKkEc=",
"x86_64-darwin": "sha256-1u7+dNjRcqKSvUTCEarVJ9fMrRUyg9y+4seZAm6Fmus="
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
}
}
-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.
@@ -41,12 +41,7 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
const messages = [user, ...assistants]
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
const lastID = userID
const lastAssistant = assistants.at(-1)!
const lastPart = lastAssistant.parts.at(-1)!
const lastPartID =
lastPart.type === "tool"
? lastPart.id
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
benchmark.setTimeout(180_000)
@@ -112,25 +107,9 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
},
})
await page.route(`**/session/${fixture.targetID}`, (route) => {
const current = new URL(route.request().url()).pathname.startsWith("/api/")
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
current
? {
data: {
...target,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
location: { directory: target.directory },
},
}
: target,
),
})
})
await page.route(`**/session/${fixture.targetID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
)
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
@@ -165,8 +144,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
parent: requests.filter((request) => request.type === "parent").length,
}
if (mode === "candidate") {
expect(requestCounts.parent).toBe(0)
expect(historyGates).toBe(0)
expect(requestCounts.parent).toBe(1)
expect(historyGates).toBe(1)
}
return { metrics, requestCounts, historyGateCount: historyGates }
}
@@ -1,6 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
@@ -34,7 +33,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
await tabA.locator('[data-slot="tab-close"] button').click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true)
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
@@ -85,21 +84,17 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/global/health") return json(route, {}, 404)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session") return json(route, [current])
if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
return json(route, {})
if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@@ -121,20 +116,7 @@ async function mockServers(page: Page, requests: string[]) {
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/api/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
data: { branch: "main", defaultBranch: "main" },
})
return json(route, {})
})
}
@@ -1,7 +1,6 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
@@ -18,7 +17,7 @@ test("session settings use the remote server context", async ({ page }) => {
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await page.keyboard.press("Control+,")
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
const dialog = page.locator(".settings-v2-dialog")
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
@@ -59,7 +58,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByText(sessionA.title).first()).toBeVisible()
await page.keyboard.press("Control+,")
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
@@ -181,36 +180,10 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true)
}
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
return json(route, { data: [] })
if (url.pathname === "/api/model/default") return json(route, { data: null })
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project") {
return json(route, [
{
id: remote ? sessionB.projectID : "project-server-a",
worktree: directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
}
if (url.pathname === "/api/project/current")
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} })
if (url.pathname === "/session/status") return json(route, {})
if (url.pathname === "/session") return json(route, sessions)
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)
@@ -243,12 +216,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
directory,
home: directory,
})
if (url.pathname === "/api/path")
return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
return json(route, {})
})
}
@@ -1,6 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
@@ -58,15 +57,11 @@ async function mockServers(page: Page) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route, url.pathname === "/api/event")
if (url.pathname === "/global/health") return json(route, {}, 404)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session/active")
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session/status")
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
if (url.pathname === "/session") return json(route, [current])
if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
@@ -95,20 +90,7 @@ async function mockServers(page: Page) {
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/api/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
data: { branch: "main", defaultBranch: "main" },
})
return json(route, {})
})
}
@@ -122,10 +104,10 @@ function json(route: Route, body: unknown, status = 200) {
})
}
function sse(route: Route, current: boolean) {
function sse(route: Route) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
body: `data: ${JSON.stringify({ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } })}\n\n`,
})
}
@@ -84,7 +84,6 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
async function openReview(page: Page) {
await page.setViewportSize({ width: 700, height: 900 })
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: "proj_review_line_comment_regression",
@@ -144,9 +143,9 @@ async function openReview(page: Page) {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
await page.getByRole("tab", { name: "Changes" }).click()
expect((await (await diffResponse).json()).data).toHaveLength(1)
expect(await (await diffResponse).json()).toHaveLength(1)
const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review)
@@ -65,7 +65,6 @@ async function switchSession(page: Page, title: string) {
async function setup(page: Page) {
await mockOpenCodeServer(page, {
protocol: "v1",
directory,
project: {
id: projectID,
@@ -20,12 +20,10 @@ const branchDiffs = [
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
test.setTimeout(120_000)
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
let detailVersion = 1
let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
protocol: "v1",
directory,
project: {
id: projectID,
@@ -57,7 +55,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
time: { created: 1700000000000, updated: 1700000000000 },
},
],
sessionStatus: () => sessionStatus,
sessionStatus: { [sessionID]: { type: "idle" } },
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
@@ -66,10 +64,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
branch: "review-pane-performance",
default_branch: "dev",
}),
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
}),
)
await page.route("**/vcs/diff**", (route) => {
@@ -91,51 +86,15 @@ test("keeps the review tree and terminal sized when both panels are open", async
),
})
})
await page.route("**/pty*", (route) =>
await page.route("**/pty", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_review_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
}),
)
await page.route("**/pty/pty_review_terminal*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_review_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}),
)
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: { ticket: "e2e-ticket", expires_in: 60 },
}),
}),
await page.route("**/pty/pty_review_terminal", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => {
@@ -184,7 +143,6 @@ test("keeps the review tree and terminal sized when both panels are open", async
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
await expect(preview).toContainText("after-1")
detailVersion = 2
sessionStatus[sessionID] = { type: "busy" }
events.push(statusEvent("busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
const refreshedDiff = page.waitForRequest((request) => {
@@ -194,7 +152,6 @@ test("keeps the review tree and terminal sized when both panels are open", async
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
sessionStatus[sessionID] = { type: "idle" }
events.push(statusEvent("idle"))
await refreshedDiff
await expect(preview).toContainText("after-2")
@@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async (
const pathBlocked = new Promise<void>((resolve) => {
releasePath = resolve
})
await page.route("**/api/path?*", async (route) => {
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
await page.route("**/path?*", async (route) => {
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
await pathBlocked
return route.fallback()
})
@@ -42,8 +42,7 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
rejectRequests.push(request.url())
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
})
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
@@ -65,9 +64,7 @@ test("shows a pending question dock", async ({ page }) => {
await question.getByRole("radio", { name: /Minimal/ }).click()
const reply = page.waitForRequest(
(request) =>
request.method() === "POST" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
)
await question.getByRole("button", { name: "Submit" }).click()
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
@@ -100,8 +97,8 @@ test("shows a pending permission dock", async ({ page }) => {
const reply = page.waitForRequest((request) => request.method() === "POST")
await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
expect(request.postDataJSON()).toEqual({ reply: "once" })
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
expect(request.postDataJSON()).toEqual({ response: "once" })
})
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
@@ -173,7 +170,6 @@ async function mockServer(
},
) {
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -27,7 +27,6 @@ test("animates todo lifecycle without replaying it across session tabs", async (
test.setTimeout(90_000)
const events: EventPayload[] = []
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
await mockOpenCodeServer(page, {
directory,
@@ -57,11 +56,9 @@ test("animates todo lifecycle without replaying it across session tabs", async (
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
sessionStatus: { [sourceID]: { type: "busy" } },
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
sessionStatus: () => sessionStatus,
todos: (sessionID) => todos[sessionID] ?? [],
})
await configurePage(page)
@@ -71,7 +68,6 @@ test("animates todo lifecycle without replaying it across session tabs", async (
const dock = page.locator('[data-component="session-todo-dock"]')
await expect(dock).toHaveCount(0)
sessionStatus[sourceID] = { type: "busy" }
events.push(statusEvent(sourceID, "busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
@@ -1,6 +1,6 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/SubagentNavigation"
@@ -72,19 +72,16 @@ async function setup(page: Page, events?: () => EventPayload[]) {
events,
eventRetry: events ? 16 : undefined,
})
// The child session resolves by ID but is absent from the session list,
// The child session resolves via /session/:id but is absent from the /session list,
// matching a subagent session that has not been loaded into the list cache yet.
await page.route(
(url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
(url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({
data: [currentSession(session(parentID, parentTitle, 1700000000000))],
cursor: {},
}),
body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
}),
)
await configurePage(page)
@@ -1,6 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const server = "http://127.0.0.1:4096"
const sessionA = session("ses_tab_a", "Tab A session")
@@ -57,15 +56,9 @@ async function mockServer(page: Page) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback()
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
return json(route, { data: [], cursor: {} })
if (url.pathname === "/session") return json(route, sessions)
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)
@@ -73,7 +66,8 @@ async function mockServer(page: Page) {
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
return json(route, {})
if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@@ -95,20 +89,7 @@ async function mockServer(page: Page) {
directory: sessionA.directory,
home: sessionA.directory,
})
if (url.pathname === "/api/path")
return json(route, {
state: sessionA.directory,
config: sessionA.directory,
worktree: sessionA.directory,
directory: sessionA.directory,
home: sessionA.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: sessionA.directory },
data: { branch: "main", defaultBranch: "main" },
})
return json(route, {})
})
}
@@ -13,7 +13,6 @@ test.use({ viewport: { width: 1440, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -47,30 +46,25 @@ test.beforeEach(async ({ page }) => {
],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/pty*", (route) => {
expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory)
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
})
})
await page.route(`**/api/pty/${ptyID}*`, (route) =>
await page.route("**/pty", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
}),
)
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
await page.route(`**/pty/${ptyID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
body: JSON.stringify({ ticket: "e2e-ticket" }),
}),
)
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
@@ -101,12 +95,12 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
const ghostty = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const created = { count: 0 }
await page.route("**/api/pty*", (route) => {
await page.route("**/pty", (route) => {
created.count += 1
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
})
})
await page.route(/ghostty-web/, async (route) => {
@@ -161,31 +155,27 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
const created = { count: 0 }
await page.route("**/api/pty*", (route) => {
await page.route("**/pty", (route) => {
created.count += 1
const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2")
const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: next }),
body: JSON.stringify(next),
})
})
await page.route(`**/api/pty/${newPtyID}*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }),
}),
await page.route(`**/pty/${newPtyID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) =>
await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
body: JSON.stringify({ ticket: "e2e-ticket" }),
}),
)
await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
@@ -217,11 +207,3 @@ function seedCachedTerminal(page: Page) {
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
)
}
function ptyLocation() {
return { directory, project: { id: projectID, directory } }
}
function ptyInfo(id: string, title: string) {
return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
}
@@ -10,7 +10,6 @@ const title = "Hidden terminal regression"
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -44,53 +43,17 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/pty*", (route) =>
await page.route("**/pty", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_hidden_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
}),
)
await page.route("**/api/pty/pty_hidden_terminal*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_hidden_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}),
await page.route("**/pty/pty_hidden_terminal", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: { ticket: "e2e-ticket", expires_in: 60 },
}),
}),
)
await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
@@ -29,10 +29,6 @@ test("keeps the terminal session alive when switching session tabs in a workspac
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toBeVisible()
await expect.poll(() => connections.length).toBe(1)
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
expect(connection.searchParams.get("location[directory]")).toBe(directory)
expect(connection.searchParams.get("ticket")).toBeNull()
await writeProbe(page)
await switchTab(page, titleB)
@@ -66,7 +62,6 @@ async function readProbe(page: Page) {
async function setup(page: Page) {
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -90,33 +85,26 @@ async function setup(page: Page) {
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/pty*", (route) =>
await page.route("**/pty", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
}),
)
await page.route(`**/api/pty/${ptyID}*`, (route) =>
await page.route(`**/pty/${ptyID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
}),
)
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => {
expect(route.request().headers()["x-opencode-ticket"]).toBe("1")
const url = new URL(route.request().url())
expect(url.searchParams.get("location[directory]")).toBe(directory)
return route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
})
})
body: JSON.stringify({ ticket: "e2e-ticket" }),
}),
)
const connections: string[] = []
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
connections.push(ws.url())
})
@@ -155,11 +143,3 @@ function session(id: string, title: string, created: number) {
function sessionHref(sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
function ptyLocation() {
return { directory, project: { id: projectID, directory } }
}
function ptyInfo() {
return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
}
+2 -12
View File
@@ -24,7 +24,7 @@ export interface MockServerConfig {
fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
sessionStatus?: unknown
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
@@ -79,11 +79,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
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 === "/session/status") return json(route, 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") ?? ""))
@@ -209,12 +205,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
return json(route, true)
}
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
return json(route, true)
}
if (
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
route.request().method() === "POST"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.18.5",
"version": "1.18.4",
"description": "",
"type": "module",
"exports": {
@@ -53,7 +53,7 @@
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
+42 -26
View File
@@ -47,7 +47,7 @@ import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { currentRoute, LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
@@ -239,26 +239,40 @@ function UiI18nBridge(props: ParentProps) {
function LayoutCompatibility(props: ParentProps) {
const global = useGlobal()
const navigate = useNavigate()
const location = useLocation()
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)))
const tabs = useTabs()
const connection = createMemo(() => {
const route = currentRoute(location.pathname, location.search)
if (route.type === "draft" && !tabs.ready()) return
const key =
route.type === "draft"
? tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === route.draftID)?.server
: route.type === "home"
? undefined
: route.server
if (!key) return server.current
return global.servers.list().find((item) => ServerConnection.key(item) === key)
})
const protocol = createMemo(() => {
const value = connection()
if (!value) return
return global.ensureServerCtx(value).sdk.protocolKind()
})
return <>{props.children}</>
createEffect(() => {
const value = protocol()
if (!value) return
settings.general.setNewLayoutRequired(value === "v2")
})
const ready = createMemo(() => {
const value = protocol()
if (!value) return false
return settings.general.newLayoutRequired() === (value === "v2")
})
return <Show when={ready()}>{props.children}</Show>
}
declare global {
@@ -582,15 +596,17 @@ export function AppInterface(props: {
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<PermissionProvider>
<NotificationProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</NotificationProvider>
</PermissionProvider>
<LayoutCompatibility>
<PermissionProvider>
<NotificationProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</NotificationProvider>
</PermissionProvider>
</LayoutCompatibility>
</TabsProvider>
)}
>
@@ -1,6 +1,5 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { Project } from "@opencode-ai/sdk/v2/client"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createMemo, onCleanup } from "solid-js"
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
@@ -14,7 +13,6 @@ import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { normalizeSessionInfo } from "@/utils/session"
export type CommandPaletteEntry = {
id: string
@@ -146,7 +144,8 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
server: ServerConnection.key(serverSDK.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
load: (search, signal) =>
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -220,7 +219,7 @@ export function createServerSessionEntries(props: {
server: ServerConnection.Key
opened: () => LocalProject[]
stored: () => Project[]
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
untitled: () => string
category: () => string
}) {
@@ -256,8 +255,7 @@ export function createServerSessionEntries(props: {
return props
.load(search, current.signal)
.then((result) =>
result.data
.map(normalizeSessionInfo)
(result.data ?? [])
.filter((session) => !session.time.archived)
.map((session) => {
const project =
@@ -266,7 +264,7 @@ export function createServerSessionEntries(props: {
id: `session:${props.server}:${session.id}`,
type: "session" as const,
title: session.title || props.untitled(),
description: project ? displayName(project) : getFilename(session.directory),
description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
category: props.category(),
directory: session.directory,
sessionID: session.id,
@@ -79,7 +79,8 @@ export function DialogHomeCommandPaletteV2(props: {
server: ServerConnection.key(props.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
load: (search, signal) =>
serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -1,4 +1,4 @@
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -28,8 +28,6 @@ import {
Switch,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useQueryClient } from "@tanstack/solid-query"
import { useParams } from "@solidjs/router"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
@@ -37,11 +35,8 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64"
import { pathKey } from "@/utils/path-key"
const CUSTOM_ID = "_custom"
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
const [store, setStore] = createStore({ selected: undefined as string | undefined })
@@ -159,7 +154,7 @@ function ProviderPicker(props: {
const settings = useSettings()
if (settings.general.newLayoutDesigns())
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
const providers = useProviders(() => props.directory?.())
const providers = useProviders(props.directory)
const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other")
@@ -231,8 +226,10 @@ function ProviderPickerV2(props: {
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const providers = useProviders(() => props.directory?.())
const providers = useProviders(props.directory)
const language = useLanguage()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const [store, setStore] = createStore({
filter: "",
active: undefined as string | undefined,
@@ -269,7 +266,19 @@ function ProviderPickerV2(props: {
const connect = (provider: string) => {
props.onPrepare?.()
props.onSelect(provider)
if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
props.onSelect(provider)
return
}
if (store.connecting) return
setStore("connecting", provider)
void serverSDK()
.client.provider.auth()
.then((response) => {
serverSync().set("provider_auth", response.data ?? {})
props.onSelect(provider)
})
.catch(() => props.onSelect(provider))
}
const move = (event: KeyboardEvent, direction: number) => {
@@ -386,17 +395,10 @@ function ProviderConnection(props: {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const queryClient = useQueryClient()
const params = useParams()
const language = useLanguage()
const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(() => props.directory?.())
const directory = () => props.directory?.() ?? decode64(params.dir)
const location = () => {
const value = directory()
return value ? { directory: value } : undefined
}
const providers = useProviders(props.directory)
const alive = { value: true }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -411,34 +413,38 @@ function ProviderConnection(props: {
const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
)
const fallback = createMemo<ConnectMethod[]>(() => [
const fallback = createMemo<ProviderAuthMethod[]>(() => [
{
type: "key" as const,
type: "api" as const,
label: language.t("provider.connect.method.apiKey"),
},
])
const [integration] = createResource(
() => ({ provider: props.provider, directory: directory() }),
(input) =>
serverSDK()
.api.integration.get({
integrationID: input.provider,
location: input.directory ? { directory: input.directory } : undefined,
})
.then((result) => result.data),
const [auth] = createResource(
() => props.provider,
async () => {
const cached = serverSync().data.provider_auth[props.provider]
if (cached) return cached
const res = await serverSDK().client.provider.auth()
if (!alive.value) return fallback()
serverSync().set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
},
)
const loading = createMemo(() => integration.loading)
const methods = createMemo<ConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
)
return values?.length ? values : fallback()
})
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
const cachedMethods = serverSync().data.provider_auth[props.provider]
const directMethod =
cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
methodIndex: directMethod as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
promptInputs: undefined as undefined | Record<string, string>,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
state: (directMethod === undefined ? "pending" : undefined) as
| undefined
| "pending"
| "complete"
| "error"
| "prompt",
error: undefined as string | undefined,
})
@@ -448,7 +454,7 @@ function ProviderConnection(props: {
| { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string }
function dispatch(action: Action) {
@@ -502,7 +508,7 @@ function ProviderConnection(props: {
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
if (value.type === "key") return language.t("provider.connect.method.apiKey")
if (value.type === "api") return language.t("provider.connect.method.apiKey")
return value.label ?? ""
}
@@ -512,7 +518,7 @@ function ProviderConnection(props: {
const hint = suffix?.[1]
return {
label: suffix ? label.slice(0, -suffix[0].length) : label,
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined,
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined,
}
}
@@ -543,22 +549,46 @@ function ProviderConnection(props: {
const method = methods()[index]
dispatch({ type: "method.select", index })
if (method.type === "api" && method.prompts?.length) {
if (!inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.inputs", inputs })
return
}
if (method.type === "oauth") {
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.pending" })
const start = Date.now()
await serverSDK()
.api.integration.oauth.connect({
integrationID: props.provider,
methodID: method.id,
inputs: inputs ?? {},
location: location(),
})
.client.provider.oauth.authorize(
{
providerID: props.provider,
method: index,
inputs,
},
{ throwOnError: true },
)
.then((x) => {
if (!alive.value) return
dispatch({ type: "auth.complete", authorization: x.data })
const elapsed = Date.now() - start
const delay = 1000 - elapsed
if (delay > 0) {
if (timer.current !== undefined) clearTimeout(timer.current)
timer.current = setTimeout(() => {
timer.current = undefined
if (!alive.value) return
dispatch({ type: "auth.complete", authorization: x.data! })
}, delay)
return
}
dispatch({ type: "auth.complete", authorization: x.data! })
})
.catch((e) => {
if (!alive.value) return
@@ -573,9 +603,9 @@ function ProviderConnection(props: {
index: 0,
})
const prompts = createMemo(() => {
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
const value = method()
return value?.type === "oauth" ? (value.prompts ?? []) : []
return value?.prompts ?? []
})
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
@@ -606,6 +636,10 @@ function ProviderConnection(props: {
setFormStore("index", next)
return
}
if (method()?.type === "api") {
dispatch({ type: "auth.inputs", inputs: value })
return
}
await selectMethod(store.methodIndex, value)
}
@@ -707,10 +741,7 @@ function ProviderConnection(props: {
})
async function complete() {
const value = directory()
await queryClient
.refetchQueries(serverSync().queryOptions.providers(value ? pathKey(value) : null))
.catch(() => undefined)
await serverSDK().client.global.dispose()
dialog.close()
showToast({
variant: "success",
@@ -774,7 +805,7 @@ function ProviderConnection(props: {
listRef = ref
}}
items={methods}
key={(m) => m?.label ?? m?.type}
key={(m) => m?.label}
onSelect={async (selected, index) => {
if (!selected) return
void selectMethod(index)
@@ -820,10 +851,13 @@ function ProviderConnection(props: {
}
setFormStore("error", undefined)
await serverSDK().api.integration.connect.key({
integrationID: props.provider,
location: location(),
key: apiKey,
await serverSDK().client.auth.set({
providerID: props.provider,
auth: {
type: "api",
key: apiKey,
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
},
})
await complete()
}
@@ -950,13 +984,12 @@ function ProviderConnection(props: {
setFormStore("error", undefined)
const result = await serverSDK()
.api.integration.oauth.complete({
integrationID: props.provider,
attemptID: store.authorization!.attemptID,
location: location(),
.client.provider.oauth.callback({
providerID: props.provider,
method: store.methodIndex,
code,
})
.then(() => ({ ok: true as const }))
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (result.ok) {
await complete()
@@ -1043,37 +1076,25 @@ function ProviderConnection(props: {
})
onMount(() => {
const poll = async () => {
const authorization = store.authorization
if (!authorization || !alive.value) return
void (async () => {
const result = await serverSDK()
.api.integration.oauth.status({
integrationID: props.provider,
attemptID: authorization.attemptID,
location: location(),
.client.provider.oauth.callback({
providerID: props.provider,
method: store.methodIndex,
})
.then((value) => ({ ok: true as const, status: value.data }))
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (!alive.value) return
if (!result.ok) {
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
const message = formatError(result.error, language.t("common.requestFailed"))
dispatch({ type: "auth.error", error: message })
return
}
if (result.status.status === "complete") {
await complete()
return
}
if (result.status.status === "failed") {
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
return
}
timer.current = setTimeout(poll, 1_000)
}
void poll()
await complete()
})()
})
return (
@@ -1157,15 +1178,15 @@ function ProviderConnection(props: {
</div>
</div>
</Match>
<Match when={method()?.type === "key"}>
<Match when={method()?.type === "api"}>
<ApiAuthView />
</Match>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.mode === "code"}>
<Match when={store.authorization?.method === "code"}>
<OAuthCodeView />
</Match>
<Match when={store.authorization?.mode === "auto"}>
<Match when={store.authorization?.method === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
+7 -3
View File
@@ -69,11 +69,15 @@ export const DialogFork: Component = () => {
const dir = base64Encode(sdk().directory)
sdk()
.api.session.fork({ sessionID, messageID: item.id })
.client.session.fork({ sessionID, messageID: item.id })
.then((forked) => {
if (!forked.data) {
showToast({ title: language.t("common.requestFailed") })
return
}
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id })
navigate(`/${dir}/session/${forked.id}`)
prompt.set(restored, undefined, { dir, id: forked.data.id })
navigate(`/${dir}/session/${forked.data.id}`)
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
@@ -8,7 +8,6 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import type { Path } from "@opencode-ai/sdk/v2/client"
import {
absoluteTreePath,
activeTreeNavigation,
@@ -29,7 +28,6 @@ import {
} from "./directory-picker-domain"
import "./dialog-select-directory-v2.css"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { getFilename } from "@opencode-ai/core/util/path"
interface DialogSelectDirectoryV2Props {
title?: string
@@ -69,13 +67,11 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) !== "v1") return
return sdk.client.path
() =>
sdk.client.path
.get()
.then((result) => result.data)
.catch(() => undefined)
},
.catch(() => undefined),
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
@@ -89,26 +85,18 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
)
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => {
const cleaned = cleanPickerInput(value)
const typed = cleaned.replace(/\/+$/, "")
const typed = cleanPickerInput(value).replace(/\/+$/, "")
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
if (!typed || typed === current) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const base = pickerRoot(cleaned) || root() || start()
if (!base) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.api.file
.find({
location: { directory: base },
query: pickerFileSearchQuery(base, value, home()),
type: "file",
limit: 20,
})
.then((result) => result.data)
const files = await sdk.client.find
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
.then((result) => result.data ?? [])
.catch(() => [])
const results = [
...directories,
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
]
return {
query: value,
@@ -127,14 +115,9 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.api.file
.list({ location: { directory: absolute } })
.then((result) =>
result.data.map((entry) => ({
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
type: entry.type,
})),
)
return sdk.client.file
.list({ directory: absolute, path: "" })
.then((result) => result.data ?? [])
.catch(() => undefined)
})
listings.set(key, request)
@@ -9,7 +9,6 @@ import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
import type { Path } from "@opencode-ai/sdk/v2/client"
interface DialogSelectDirectoryProps {
title?: string
@@ -60,11 +59,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) !== "v1") return
async () => {
return sdk.client.path
.get()
.then((result) => result.data)
.then((x) => x.data)
.catch(() => undefined)
},
{ initialValue: undefined },
@@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => {
filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => {
if (!x || x.status === "pending" || toggle.isPending) return
if (!x || toggle.isPending) return
toggle.mutate(x.name)
}}
>
@@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={enabled()}
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
disabled={toggle.isPending && toggle.variables === i.name}
onChange={() => {
if (toggle.isPending) return
toggle.mutate(i.name)
@@ -16,7 +16,6 @@ import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { detectServerProtocol } from "@/utils/server-protocol"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
@@ -264,13 +263,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
if (
!settings.general.newLayoutDesigns() &&
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
if (options.navigateOnAdd === false) {
@@ -315,13 +307,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (
!settings.general.newLayoutDesigns() &&
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
@@ -359,10 +344,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
)
const sortedItems = createMemo(() => {
const raw = items()
const list = settings.general.newLayoutDesigns()
? raw
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
const list = items()
if (!list.length) return list
const active = current()
const order = new Map(list.map((url, index) => [url, index] as const))
@@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => {
test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = []
const sdk = {
api: {
file: {
find: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
client: {
find: {
files: (input: { directory: string }) => {
directories.push(input.directory)
return Promise.resolve({ data: [] })
},
},
@@ -152,29 +152,6 @@ test("resolves directory autocomplete from the current browser root", async () =
expect(directories).toEqual(["/repo", "/repo/src"])
})
test("searches from an absolute root without a default base", async () => {
const directories: string[] = []
const sdk = {
api: {
file: {
list: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({
data: [
{ path: "Users/", type: "directory" },
{ path: "tmp/", type: "directory" },
],
})
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
expect(await search("/")).toEqual(["/Users", "/tmp"])
expect(directories).toEqual(["/"])
})
test("identifies the next directory level to preload", () => {
expect(
preloadTreeDirectories("src/", [
@@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
let current = 0
const scoped = (value: string) => {
const raw = normalizePickerDrive(value)
const root = pickerRoot(raw)
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
const base = args.base()
if (!base) return
const raw = normalizePickerDrive(value)
if (!raw) return { directory: trimPickerPath(base), path: "" }
const home = args.home()
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
const root = pickerRoot(raw)
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
return { directory: trimPickerPath(base), path: raw }
}
@@ -342,17 +342,14 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const key = trimPickerPath(directory)
const existing = cache.get(key)
if (existing) return existing
const request = args.sdk.api.file
.list({ location: { directory: key } })
.then((result) => result.data)
const request = args.sdk.client.file
.list({ directory: key, path: "" })
.then((result) => result.data ?? [])
.catch(() => [])
.then((nodes) =>
nodes
.filter((node) => node.type === "directory")
.map((node) => {
const relative = trimPickerPath(normalizePickerDrive(node.path))
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
}),
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
)
cache.set(key, request)
return request
@@ -374,9 +371,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const results = await args.sdk.api.file
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.then((result) => result.data.map((entry) => entry.path))
const results = await args.sdk.client.find
.files({ directory: input.directory, query, type: "directory", limit: 50 })
.then((result) => result.data ?? [])
.catch(() => [])
if (!active()) return []
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
+7 -21
View File
@@ -1,7 +1,6 @@
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global"
@@ -71,26 +70,13 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
if ((await serverCtx().sdk.protocol) !== "v1") return
const project = await serverCtx()
.sdk.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
.then((result) => result.data)
if (!project) return
// const project = await serverCtx().sdk.api.project.update({
// projectID: props.project.id,
// name,
// icon: { color: store.color || "", override: store.iconOverride || "" },
// commands: { start },
// })
serverCtx().sync.set("project", (items) =>
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
)
await serverCtx().sdk.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
@@ -447,16 +447,15 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
},
view: {
placeholder: designPlaceholder,
get agent() {
return props.controls.agents.visible && props.controls.agents.options.length > 0
agent:
props.controls.agents.visible && props.controls.agents.options.length > 0
? {
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
current: () => props.controls.agents.current,
onSelect: (value: string) => props.controls.agents.select(value),
onSelect: props.controls.agents.select,
keybind: () => command.keybindParts("agent.cycle"),
}
: undefined
},
: undefined,
variant: {
options: () => variants().map((value) => ({ id: value, label: value })),
current: () => props.controls.model.selection.variant.current() ?? "default",
+1 -1
View File
@@ -709,7 +709,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
title: cmd.name,
description: cmd.description,
type: "custom" as const,
// source: cmd.source,
source: cmd.source,
}))
return [...custom, ...builtin]
@@ -7,11 +7,6 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
const sessionCreateInputs: Array<{
agent?: string
model?: { id: string; providerID: string; variant?: string }
location?: { directory: string }
}> = []
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
const optimistic: Array<{
directory?: string
@@ -24,15 +19,11 @@ const optimistic: Array<{
}> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const sessionDirectories: Record<string, string> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const sentShell: string[] = []
const syncedDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = []
const promptInputs: unknown[] = []
const sentCommands: unknown[] = []
const commands: Array<{ name: string }> = []
let serverSessionSyncs = 0
let params: { id?: string } = {}
let search: { draftId?: string } = {}
@@ -41,7 +32,7 @@ let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({
prompt: promptValue,
cursor: 0,
@@ -73,39 +64,23 @@ const prompt = {
const clientFor = (directory: string) => {
createdClients.push(directory)
return {
api: {
session: {
create: async (input: (typeof sessionCreateInputs)[number]) => {
await createSessionGate
const location = input.location?.directory ?? directory
createdSessions.push(location)
sessionCreateInputs.push(input)
return {
id: `session-${createdSessions.length}`,
projectID: "project",
agent: input.agent,
model: input.model,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: `New session ${createdSessions.length}`,
location: { directory: location },
}
},
prompt: async (input: unknown) => {
sentPrompts.push(directory)
promptInputs.push(input)
return { data: undefined }
},
command: async (input: unknown) => {
sentCommands.push(input)
},
shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input)
},
},
},
session: {
create: async () => {
await createSessionGate
createdSessions.push(directory)
return {
data: {
id: `session-${createdSessions.length}`,
title: `New session ${createdSessions.length}`,
},
}
},
shell: async () => {
sentShell.push(directory)
return { data: undefined }
},
prompt: async () => ({ data: undefined }),
promptAsync: async () => ({ data: undefined }),
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
@@ -115,6 +90,27 @@ const clientFor = (directory: string) => {
}
}
const api = {
session: {
async create(input: { location: { directory: string } }) {
await createSessionGate
createdSessions.push(input.location.directory)
const session = {
id: `session-${createdSessions.length}`,
title: `New session ${createdSessions.length}`,
}
sessionDirectories[session.id] = input.location.directory
return session
},
async shell(input: { sessionID: string }) {
sentShell.push(sessionDirectories[input.sessionID] ?? "/repo/main")
},
async prompt() {},
async command() {},
async interrupt() {},
},
}
beforeAll(async () => {
const rootClient = clientFor("/repo/main")
@@ -197,8 +193,8 @@ beforeAll(async () => {
const sdk = {
scope: "local",
directory: "/repo/main",
api,
client: rootClient,
api: rootClient.api,
url: "http://localhost:4096",
createClient(opts: any) {
return clientFor(opts.directory)
@@ -210,7 +206,7 @@ beforeAll(async () => {
mock.module("@/context/sync", () => ({
useSync: () => () => ({
data: { command: commands },
data: { command: [] },
session: {
optimistic: {
add: (value: {
@@ -237,9 +233,6 @@ beforeAll(async () => {
session: {
remember: () => undefined,
set: () => undefined,
sync: async () => {
serverSessionSyncs++
},
},
child: (directory: string) => {
syncedDirectories.push(directory)
@@ -281,17 +274,11 @@ beforeAll(async () => {
beforeEach(() => {
createdClients.length = 0
createdSessions.length = 0
sessionCreateInputs.length = 0
enabledAutoAccept.length = 0
optimistic.length = 0
optimisticSeeded.length = 0
promoted.length = 0
promotedDrafts.length = 0
sentPrompts.length = 0
promptInputs.length = 0
sentCommands.length = 0
commands.length = 0
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
params = {}
search = {}
sentShell.length = 0
@@ -300,8 +287,8 @@ beforeEach(() => {
variant = undefined
permissionServer = "server-a"
createSessionGate = undefined
serverSessionSyncs = 0
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
})
describe("prompt submit worktree selection", () => {
@@ -334,24 +321,8 @@ describe("prompt submit worktree selection", () => {
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sessionCreateInputs).toEqual([
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-a" },
},
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-b" },
},
])
expect(sentShell).toEqual([
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
expect(serverSessionSyncs).toBe(0)
expect(promoted).toEqual([
{ directory: "/repo/worktree-a", sessionID: "session-1" },
{ directory: "/repo/worktree-b", sessionID: "session-2" },
@@ -472,7 +443,6 @@ describe("prompt submit worktree selection", () => {
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
await Bun.sleep(0)
expect(optimistic).toHaveLength(1)
expect(optimistic[0]).toMatchObject({
@@ -481,56 +451,6 @@ describe("prompt submit worktree selection", () => {
model: { providerID: "provider", modelID: "model", variant: "high" },
},
})
expect(sentPrompts).toEqual(["/repo/main"])
expect(promptInputs[0]).toMatchObject({
sessionID: "session-1",
text: "ls",
files: [],
agents: [],
})
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([
{ id: expect.stringMatching(/^prt_/), type: "text", text: "ls" },
])
})
test("submits slash commands through the current session API", async () => {
params = { id: "session-1" }
variant = "high"
commands.push({ name: "review" })
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(sentCommands).toEqual([
{
sessionID: "session-1",
id: expect.stringMatching(/^msg_/),
command: "review",
arguments: "staged changes",
agent: "agent",
model: { id: "model", providerID: "provider", variant: "high" },
files: [],
},
])
expect(serverSessionSyncs).toBe(0)
})
test("uses an injected model selection", async () => {
@@ -591,8 +511,7 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event)
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
expect(optimisticSeeded).toEqual([true])
})
})
@@ -162,7 +162,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
agent: input.draft.agent,
model: input.draft.model,
variant: input.draft.variant,
legacyParts: requestParts,
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
files: requestParts.flatMap((part) => {
if (part.type !== "file") return []
@@ -13,7 +13,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "./updater-action"
import {
monoDefault,
@@ -125,16 +125,15 @@ export const SettingsGeneral: Component = () => {
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const [shells] = createResource(
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
() => (protocol() === "v1" ? serverSdk() : undefined),
(sdk) =>
sdk
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
@@ -325,27 +324,29 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Select
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
<Show when={protocol() === "v1"}>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Select
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -41,7 +41,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
const serverSDK = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync()
const providers = useProviders(() => undefined)
const providers = useProviders()
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => {
@@ -1,4 +1,4 @@
import { Component, createMemo, createSignal, startTransition } from "solid-js"
import { Component, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Icon } from "@opencode-ai/ui/icon"
@@ -11,9 +11,6 @@ import { SettingsModelsV2 } from "./models"
import "./settings-v2.css"
import { SettingsServersV2 } from "./servers"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLayout } from "@/context/layout"
import { useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync"
export const DialogSettings: Component<{
sessionID?: string
@@ -22,20 +19,7 @@ export const DialogSettings: Component<{
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const layout = useLayout()
const tabs = useTabs()
const serverSync = useServerSync()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const directory = createMemo(() => {
const route = layout.route()
if (route.type === "dir-new-sesssion") return route.dir
if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined
}
if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
return undefined
})
const showProviders = () => {
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
@@ -103,7 +87,7 @@ export const DialogSettings: Component<{
<SettingsServersV2 />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
<SettingsProvidersV2 onBack={showProviders} />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 />
@@ -10,7 +10,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
import {
monoDefault,
@@ -92,6 +92,7 @@ export const SettingsGeneralV2: Component<{
const settings = useSettings()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
@@ -122,14 +123,12 @@ export const SettingsGeneralV2: Component<{
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource(
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
() => (protocol() === "v1" ? serverSdk() : undefined),
(sdk) =>
sdk
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
@@ -284,26 +283,28 @@ export const SettingsGeneralV2: Component<{
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
<Show when={protocol() === "v1"}>
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
</Show>
<SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -695,7 +696,9 @@ export const SettingsGeneralV2: Component<{
<div class="settings-v2-tab-body">
<Show when={settings.general.layoutTransitionAvailable()}>
<InterfaceSection />
<Show when={!settings.general.newLayoutRequired()}>
<InterfaceSection />
</Show>
</Show>
<Show when={settings.general.newInterfaceNoticeVisible()}>
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
@@ -29,21 +29,18 @@ const PROVIDER_NOTES = [
const PROVIDER_ICON_SIZE = 16
export const SettingsProvidersV2: Component<{
directory: Accessor<string | undefined>
onBack?: () => void
}> = (props) => {
export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync()
const providers = useProviders(props.directory)
const providers = useProviders()
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
}
const connected = createMemo(() => {
@@ -238,9 +235,7 @@ export const SettingsProvidersV2: Component<{
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
<p class="settings-v2-provider-description">
{language.t("settings.providers.custom.description")}
</p>
<p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p>
</div>
</div>
<ButtonV2
@@ -278,12 +278,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
dialogDead = true
dialogRun += 1
})
const sortedServers = createMemo(() => {
const list = settings.general.newLayoutDesigns()
? global.servers.list()
: global.servers.list().filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
return listServersByHealth(list, server.key, global.servers.health)
})
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
const toggleMcp = useMcpToggle()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
@@ -310,7 +305,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
{!settings.general.newLayoutDesigns() && (
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
{global.servers.list().length > 0 ? `${global.servers.list().length} ` : ""}
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
)}
+26 -59
View File
@@ -178,6 +178,7 @@ export const Terminal = (props: TerminalProps) => {
// 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 client = sdk().client
const url = sdk().url
const auth = connection.http
const username = auth?.username ?? "opencode"
@@ -240,21 +241,10 @@ export const Terminal = (props: TerminalProps) => {
}
}
const pushSize = async (cols: number, rows: number) => {
if ((await sdk().protocol) === "v1") {
return sdk()
.client.pty.update({
ptyID: id,
size: { cols, rows },
})
.catch((err) => {
debugTerminal("failed to sync terminal size", err)
})
}
return sdk()
.api.pty.update({
const pushSize = (cols: number, rows: number) => {
return client.pty
.update({
ptyID: id,
location: { directory },
size: { cols, rows },
})
.catch((err) => {
@@ -532,54 +522,34 @@ export const Terminal = (props: TerminalProps) => {
local.onConnectError?.(err)
}
const gone = async () => {
if ((await sdk().protocol) === "v1") {
return sdk()
.client.pty.get({ ptyID: id }, { throwOnError: false })
.then((result) => result.response.status === 404)
.catch((err) => {
debugTerminal("failed to inspect terminal session", err)
return false
})
}
return sdk()
.api.pty.get({ ptyID: id, location: { directory } })
.then((result) => result.data.status === "exited")
const gone = () =>
client.pty
.get({ ptyID: id }, { throwOnError: false })
.then((result) => result.response.status === 404)
.catch((err) => {
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
debugTerminal("failed to inspect terminal session", err)
return false
})
}
const connectToken = async () => {
if ((await sdk().protocol) === "v1") {
const result = await sdk()
.client.pty.connectToken(
{ ptyID: id, directory },
{
throwOnError: false,
headers: { "x-opencode-ticket": "1" },
},
)
.catch((err: unknown) => {
if (err instanceof Error && err.message.includes("Request is not supported")) return
throw err
})
if (!result) return
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
if (result.response.status === 404 || result.response.status === 405) return
if (result.response.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
}
// return sdk()
// .api.pty.connectToken({
// ptyID: id,
// location: { directory },
// "x-opencode-ticket": "1",
// })
// .then((result) => result.data.ticket)
const result = await 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}`)
}
const retry = (err: unknown) => {
@@ -609,14 +579,11 @@ export const Terminal = (props: TerminalProps) => {
fail(err)
return undefined
})
const protocol = await sdk().protocol
// if (protocol === "v2" && !ticket) return
if (once.value) return
if (disposed) return
const socket = new WebSocket(
terminalWebSocketURL({
protocol,
url,
id,
directory,
@@ -120,7 +120,8 @@ export function TabNavItem(props: {
const ctx = serverCtx()
const session = props.session()
if (!ctx || !session) return
await ctx.sdk.api.session.rename({ sessionID: session.id, title })
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
await client.session.update({ sessionID: session.id, title })
}
const closeRename = async (save: boolean) => {
+2 -3
View File
@@ -28,7 +28,6 @@ import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt"
import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
import { normalizeSessionInfo } from "@/utils/session"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@@ -268,9 +267,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
},
({ route, sdk }) =>
sdk.api.session
sdk.client.session
.get({ sessionID: route.sessionId })
.then(normalizeSessionInfo)
.then((x) => x.data)
.catch(() => {}),
)
+1 -2
View File
@@ -134,8 +134,7 @@ export const createDirSyncContext = (
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
if ((await serverSDK.protocol) !== "v1") return
await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } })
await serverSDK.api.session.archive({ sessionID, directory })
current()[1](
"session",
produce((draft) => {
+3 -11
View File
@@ -204,18 +204,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
serverSDK()
.api.file.find(
{
location: { directory: sdk().directory },
query,
type: dirs === "true" ? "directory" : "file",
limit: options?.limit,
},
{ signal: options?.signal },
)
sdk()
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
.then(
(x) => x.data.map((entry) => path.normalize(entry.path)),
(x) => (x.data ?? []).map(path.normalize),
(error) => {
if (options?.signal?.aborted) throw error
return []
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
import type { AgentApi, CatalogApi, CommandApi, ProjectApi, ReferenceApi } from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import {
bootstrapDirectory,
@@ -17,8 +17,6 @@ import type { State, VcsCache } from "./types"
import { ServerScope } from "@/utils/server-scope"
import type { ServerApi } from "@/utils/server"
type ProjectApi = ServerApi["project"]
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
const api = {
agent: { list: async () => ({ location: {}, data: [] }) },
@@ -75,14 +73,14 @@ function directoryState() {
}
describe("bootstrapDirectory", () => {
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
test("marks a loading directory partial during bootstrap and complete after success", async () => {
const mcpReads: string[] = []
const [store, setStore] = directoryState()
await bootstrapDirectory({
directory: "/project",
scope: ServerScope.local,
mcp: true,
mcp: false,
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
@@ -92,7 +90,6 @@ describe("bootstrapDirectory", () => {
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 () => {
@@ -109,14 +106,6 @@ describe("bootstrapDirectory", () => {
return { data: {} }
},
},
experimental: {
resource: {
list: async () => {
mcpReads.push("resource")
return { data: {} }
},
},
},
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient,
api,
@@ -126,7 +115,6 @@ describe("bootstrapDirectory", () => {
loadSessions() {},
translate: (key) => key,
queryClient: new QueryClient(),
protocol: Promise.resolve("v1"),
})
expect(store.status).toBe("partial")
@@ -134,7 +122,7 @@ describe("bootstrapDirectory", () => {
await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete")
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
expect(mcpReads).toEqual([])
})
})
@@ -202,7 +190,7 @@ describe("query keys", () => {
calls.push(input)
return {
location: {},
data: [{ name: "review", template: "Review files" /* source: "command" as const */ }],
data: [{ name: "review", template: "Review files", source: "command" as const }],
}
},
} as unknown as CommandApi
@@ -210,7 +198,7 @@ describe("query keys", () => {
const result = await loadCommands("/repo", api)
expect(calls).toEqual([{ location: { directory: "/repo" } }])
expect(result).toEqual([{ name: "review", template: "Review files" /* source: "command" */ }])
expect(result).toEqual([{ name: "review", template: "Review files", source: "command" }])
})
test("loads projects from the current endpoint", async () => {
@@ -16,12 +16,18 @@ import type {
CommandInfo,
CommandListInput,
CommandListOutput,
McpApi,
PathGetInput,
PathGetOutput,
PermissionApi,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectListOutput,
QuestionApi,
ReferenceListInput,
ReferenceListOutput,
SessionApi,
VcsApi,
} from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
@@ -44,7 +50,6 @@ import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import { normalizeSessionInfo } from "@/utils/session"
import type { ServerProtocol } from "@/utils/server-protocol"
import type { ServerApi } from "@/utils/server"
type GlobalStore = {
ready: boolean
@@ -116,10 +121,9 @@ type ProjectApi = {
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
}
type McpApi = ServerApi["mcp"]
type PermissionApi = ServerApi["permission"]
type QuestionApi = ServerApi["question"]
type VcsApi = ServerApi["vcs"]
type PathApi = {
readonly get: (input?: PathGetInput) => Promise<PathGetOutput>
}
export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
queryOptions({
@@ -139,7 +143,7 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient
serverAPI: CatalogApi & { readonly project: ProjectApi }
serverAPI: CatalogApi & { readonly path: PathApi; readonly project: ProjectApi }
protocol?: Promise<ServerProtocol>
scope: ServerScope
requestFailedTitle: string
@@ -154,7 +158,7 @@ export async function bootstrapGlobal(input: {
input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.path)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
@@ -285,26 +289,17 @@ export const loadCommands = (
agent: command.agent,
model: providerID && id ? { providerID, id } : undefined,
subtask: command.subtask,
// source: command.source === "skill" ? undefined : command.source,
source: command.source === "skill" ? undefined : command.source,
}
})
}
return api.list({ location: { directory } }).then((result) => result.data)
})
export const loadPathQuery = (
scope: ServerScope,
directory: string | null,
sdk: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: PathApi) =>
queryOptions<Path>({
queryKey: [scope, directory, "path"],
queryFn: async () => {
if ((await protocol) !== "v1")
return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" }
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
},
queryFn: () => retry(() => api.get(directory ? { location: { directory } } : undefined)),
})
export const loadReferencesQuery = (
@@ -333,6 +328,7 @@ export async function bootstrapDirectory(input: {
readonly agent: AgentListApi
readonly command: CommandListApi
readonly mcp: McpApi
readonly path: PathApi
readonly permission: PermissionApi
readonly project: ProjectApi
readonly question: QuestionApi
@@ -377,33 +373,6 @@ export async function bootstrapDirectory(input: {
.then((data) => input.setStore("agent", data)),
() =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
() =>
retry(() =>
(async () => {
if ((await input.protocol) !== "v1") return
const x = await input.sdk.session.status()
if (!input.session) {
input.setStore("session_status", x.data!)
return
}
const statuses = x.data ?? {}
input.session.set(
"session_status",
produce((draft) => {
for (const sessionID of Object.keys(draft)) {
if (statuses[sessionID]) continue
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
}
}),
)
for (const [sessionID, status] of Object.entries(statuses)) {
input.session.set("session_status", sessionID, reconcile(status))
}
await Promise.all(
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
)
})(),
),
!seededProject &&
(() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
@@ -412,20 +381,19 @@ export async function bootstrapDirectory(input: {
!seededPath &&
(() =>
input.queryClient
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol))
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.path))
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
() =>
retry(async () => {
if ((await input.protocol) !== "v1") return
return input.sdk.vcs.get().then((result) => {
const next = { branch: result.data?.branch, default_branch: result.data?.default_branch }
retry(() =>
input.api.vcs.get({ location: { directory: input.directory } }).then((result) => {
const next = { branch: result.data.branch, default_branch: result.data.defaultBranch }
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
})
}),
}),
),
input.mcp &&
(() =>
loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) =>
@@ -508,16 +476,9 @@ export async function bootstrapDirectory(input: {
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
input.mcp &&
(() =>
input.queryClient.fetchQuery(
loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
)),
input.mcp &&
(() =>
input.queryClient.fetchQuery(
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
)),
(() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))),
() =>
input.queryClient
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
@@ -211,19 +211,19 @@ export function applyDirectoryEvent(input: {
}))
break
}
// case "session.archived": {
// const properties = event.properties as { sessionID: string }
// const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
// if (!result.found) break
// const info = input.store.session[result.index]
// input.setStore(
// "session",
// produce((draft) => void draft.splice(result.index, 1)),
// )
// cleanupSessionCaches(input.setStore, properties.sessionID)
// if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
// break
// }
case "session.archived": {
const properties = event.properties as { sessionID: string }
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
if (!result.found) break
const info = input.store.session[result.index]
input.setStore(
"session",
produce((draft) => void draft.splice(result.index, 1)),
)
cleanupSessionCaches(input.setStore, properties.sessionID)
if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
case "session.moved": {
const properties = event.properties as {
sessionID: string
+5 -17
View File
@@ -8,7 +8,6 @@ import { useServerSDK } from "./server-sdk"
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
import { normalizeProjectInfo } from "./global-sync/utils"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key"
import { decode64 } from "@/utils/base64"
@@ -570,22 +569,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
continue
}
const projectID = project.id
void (async () => {
const sdk = serverSdk()
if ((await sdk.protocol) !== "v1") return
return sdk.client.project
.update({ projectID, directory: worktree, icon: { color } })
.then((response) => response.data)
.then((result) => {
if (!result) return
serverSync().set("project", (items) =>
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
)
})
})().catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
})
void serverSdk()
.client.project.update({ projectID: project.id, directory: worktree, icon: { color } })
.catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
})
}
})
+1 -1
View File
@@ -26,7 +26,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
name: "Models",
gate: false,
init: (props: { directory?: Accessor<string | undefined> } = {}) => {
const providers = useProviders(() => props.directory?.())
const providers = useProviders(props.directory)
const [store, setStore, _, ready] = persisted(
Persist.global("model", ["model.v1"]),
+15 -28
View File
@@ -13,7 +13,6 @@ import { type DraftTab, useTabs } from "./tabs"
import { useSettings } from "./settings"
import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
import { normalizePermissionRequest } from "./global-sync/utils"
import {
acceptKey,
directoryAcceptKey,
@@ -245,25 +244,9 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
const respond: PermissionRespondFn = (request) => {
if (meta.disposed) return
input.sdk.api.permission
.reply({
sessionID: request.sessionID,
requestID: request.permissionID,
reply: request.response,
location: request.directory ? { directory: request.directory } : undefined,
})
.catch(() => {
responded.delete(request.permissionID)
})
}
const list = async (directory: string) => {
if ((await input.sdk.protocol) === "v1") {
return (await input.sdk.client.permission.list({ directory })).data ?? []
}
return input.sdk.api.permission.request
.list({ location: { directory } })
.then((result) => result.data.map(normalizePermissionRequest))
input.sdk.client.permission.respond(request).catch(() => {
responded.delete(request.permissionID)
})
}
function respondOnce(permission: PermissionRequest, directory?: string) {
@@ -361,12 +344,14 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
}),
)
list(directory)
.then((permissions) => {
input.sdk.client.permission
.list({ directory })
.then((x) => {
if (meta.disposed) return
if (!isAutoAcceptingDirectory(directory)) return
for (const permission of permissions) {
void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory))
for (const perm of x.data ?? []) {
if (!perm?.id) continue
void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory))
}
})
.catch(() => undefined)
@@ -393,14 +378,16 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
}),
)
list(directory)
.then((permissions) => {
input.sdk.client.permission
.list({ directory })
.then((x) => {
if (meta.disposed) return
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const permission of permissions) {
for (const perm of x.data ?? []) {
if (!perm?.id) continue
void respondPending(
permission,
perm,
directory,
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
)
-1
View File
@@ -424,7 +424,6 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
return {
scope: serverSDK.scope,
protocol: serverSDK.protocol,
directory,
client,
api: createCompatibleApi({
@@ -114,7 +114,7 @@ describe("v2 session reducer", () => {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
callID: "call_1",
metadata: {},
structured: {},
content: [{ type: "text", text: "done" }],
executed: true,
},
@@ -269,18 +269,13 @@ export function createV2SessionReducer() {
...tool,
executed: event.data.executed,
providerState: event.data.state,
// structured: {}, content: []
state: { status: "running", input: event.data.input, metadata: {} },
state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { ...tool.time, ran: event.created },
}))
case "session.tool.progress":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
tool.state.status === "running"
? {
...tool,
// state: { ...tool.state, structured: event.data.structured, content: event.data.content },
state: { ...tool.state, metadata: event.data.metadata },
}
? { ...tool, state: { ...tool.state, structured: event.data.structured, content: event.data.content } }
: tool,
)
case "session.tool.success":
@@ -293,10 +288,9 @@ export function createV2SessionReducer() {
state: {
status: "completed",
input: tool.state.input,
// structured: event.data.structured,
metadata: event.data.metadata,
structured: event.data.structured,
content: event.data.content,
// result: event.data.result,
result: event.data.result,
},
time: { ...tool.time, completed: event.created },
}
@@ -311,11 +305,10 @@ export function createV2SessionReducer() {
state: {
status: "error",
input: typeof tool.state.input === "string" ? {} : tool.state.input,
// structured: tool.state.status === "running" ? tool.state.structured : {},
metadata: event.data.metadata ?? (tool.state.status === "running" ? tool.state.metadata : {}),
content: event.data.content,
structured: tool.state.status === "running" ? tool.state.structured : {},
content: tool.state.status === "running" ? tool.state.content : [],
error: event.data.error,
// result: event.data.result,
result: event.data.result,
},
time: { ...tool.time, completed: event.created },
}
@@ -1,11 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { retry } from "@opencode-ai/core/util/retry"
import type { OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise"
import type { MessageApi, OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise"
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server"
type MessageApi = ServerApi["message"]
const session = (id: string, parentID?: string): Session => ({
id,
@@ -266,7 +263,7 @@ describe("server session", () => {
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
})
test("extends a current page to include the user for split assistant turns", async () => {
test("reprojects current assistants when an older page supplies their user", async () => {
const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
const assistant = (id: string, created: number) => ({
id,
@@ -285,22 +282,17 @@ describe("server session", () => {
{ data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } },
{ data: [assistants[0], user], cursor: { previous: null, next: null } },
]
const requests: unknown[] = []
const messageApi = {
list: async (input: unknown) => {
requests.push(input)
return pages.shift()!
},
list: async () => pages.shift()!,
} as unknown as MessageApi
const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi)
store.remember(session("root"))
await store.sync("root")
expect(store.data.message.root).toEqual([])
await store.history.loadMore("root")
expect(requests).toEqual([
{ sessionID: "root", limit: 20, order: "desc" },
{ sessionID: "root", limit: 20, cursor: "older" },
])
expect(store.data.message.root.map((message) => message.id)).toEqual([
user.id,
...assistants.map((item) => item.id),
+10 -33
View File
@@ -1,6 +1,6 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { MessageApi, OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
import type {
Message,
OpencodeClient,
@@ -21,9 +21,6 @@ import { normalizeSessionInfo } from "@/utils/session"
import { normalizeSessionMessages } from "@/utils/session-message"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
import type { ServerApi } from "@/utils/server"
type MessageApi = ServerApi["message"]
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
@@ -33,17 +30,6 @@ const historyMessagePageSize = 200
const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set()
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
const boundary = source.find(
(message) =>
message.type === "user" ||
message.type === "shell" ||
message.type === "assistant" ||
(message.type === "synthetic" && message.description?.trim()),
)
return boundary?.type === "assistant"
}
type OptimisticItem = {
message: Message
parts: Part[]
@@ -539,20 +525,11 @@ export function createServerSession(
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
if (messageApi && (await options?.protocol) !== "v1") {
const request = (cursor?: string) =>
(options?.retry ?? retry)(() => {
onAttempt?.()
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
})
const first = await request(before)
const pages = [first]
while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) {
const response = await request(pages.at(-1)!.cursor.next ?? undefined)
pages.push(response)
if (!response.data.length) break
}
const response = pages.at(-1)!
const source = pages.flatMap((page) => page.data).toReversed()
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return messageApi.list(before ? { sessionID, limit, cursor: before } : { sessionID, limit, order: "desc" })
})
const source = [...response.data].reverse()
const normalized = normalizeSessionMessages(sessionID, source)
return {
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
@@ -957,10 +934,10 @@ export function createServerSession(
})
if (event.type === "session.usage.updated" && info)
remember({ ...info, cost: event.data.cost, tokens: event.data.tokens })
// if (event.type === "session.archived") {
// if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } })
// evict([sessionID])
// }
if (event.type === "session.archived") {
if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } })
evict([sessionID])
}
if (event.type === "session.execution.started") setData("session_status", sessionID, { type: "busy" })
if (
event.type === "session.execution.succeeded" ||
+2 -5
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type {
McpApi,
McpListInput,
McpResourceCatalogInput,
SessionApi,
@@ -13,9 +14,6 @@ import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/sessio
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server"
type McpApi = ServerApi["mcp"]
describe("MCP queries", () => {
test("loads current servers for the requested location", async () => {
@@ -66,7 +64,7 @@ describe("MCP queries", () => {
})
describe("active session query", () => {
test("loads active sessions immediately and once per server cache", async () => {
test("loads active sessions once per server cache", async () => {
let calls = 0
const queryClient = new QueryClient()
const options = loadActiveSessionsQuery(ServerScope.local, {
@@ -79,7 +77,6 @@ describe("active session query", () => {
expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } })
expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } })
expect(calls).toBe(1)
expect(options.enabled).toBe(true)
expect([...options.queryKey]).toEqual([ServerScope.local, "activeSessions"])
})
+5 -6
View File
@@ -157,7 +157,7 @@ export const loadActiveSessionsQuery = (
queryOptions<SessionActiveOutput, Error, SessionActiveOutput, readonly [ServerScope, "activeSessions"]>({
queryKey: [scope, "activeSessions"] as const,
queryFn: () => api.active(),
enabled: true,
enabled: false,
staleTime: Number.POSITIVE_INFINITY,
gcTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
@@ -188,8 +188,7 @@ function makeQueryOptionsApi(
projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) =>
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
path: (directory: PathKey | null) =>
loadPathQuery(scope, directory, directory ? sdkFor(directory) : serverSDK(), protocol),
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.path),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol),
references: (directory: PathKey) =>
loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol),
@@ -243,8 +242,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
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)) {
for (const [sessionID, status] of Object.entries(statuses)) {
session.set("session_status", sessionID, reconcile(status))
void session.resolve(sessionID).catch(() => undefined)
}
return Object.fromEntries(
@@ -582,7 +581,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
children.mark(key)
if (
event.current?.type === "session.moved" ||
// event.current?.type === "session.archived" ||
event.current?.type === "session.archived" ||
event.current?.type === "session.forked" ||
eventType === "command.updated" ||
eventType === "config.updated" ||
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import {
hasExistingWebState,
isAppUpgrade,
layoutTransitionState,
maximumSunsetTimeout,
@@ -24,12 +23,6 @@ describe("layout transition", () => {
expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
})
test("classifies web profiles from existing settings or a recorded version", () => {
expect(hasExistingWebState("{}", undefined)).toBe(true)
expect(hasExistingWebState(null, "1.17.19")).toBe(true)
expect(hasExistingWebState(null, undefined)).toBe(false)
})
test("preserves explicit and default layout preferences", () => {
expect(resolveNewLayoutDesigns(false, false, true)).toBe(false)
expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false)
+8 -13
View File
@@ -89,10 +89,6 @@ export function shouldDisplayTabsToast(
return isAppUpgrade(previous, current) || (!previous && existingInstall)
}
export function hasExistingWebState(settings: Promise<string> | string | null, previousVersion: string | undefined) {
return settings !== null || previousVersion !== undefined
}
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
if (!current) return false
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
@@ -224,7 +220,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
gate: false,
init: () => {
const platform = usePlatform()
const [store, setStore, settingsInit, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const [launch, setLaunch, , launchReady] = persisted(
"app-version.v1",
createStore<{ version?: string }>({ version: undefined }),
@@ -234,6 +230,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
migrationApplied: false,
previous: undefined as string | undefined,
})
const [newLayoutRequired, setNewLayoutRequired] = createSignal(false)
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
@@ -254,7 +251,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
const layoutTransition = createMemo(() =>
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
)
const newLayoutDesigns = createMemo(() => {
const preferredNewLayout = createMemo(() => {
if (layoutUpgrade()) return true
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
if (!layoutTransitionClassified()) {
@@ -270,6 +267,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
)
})
const newLayoutDesigns = createMemo(() => newLayoutRequired() || preferredNewLayout())
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
if (sunset && !oldInterfaceRetired()) {
@@ -297,12 +295,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setLaunch("version", platform.version)
})
createEffect(() => {
if (!ready() || !launchState.classified || platform.platform !== "web") return
if (layoutTransitionClassified()) return
setStore("general", "layoutTransitionEligible", hasExistingWebState(settingsInit, launchState.previous))
})
createEffect(() => {
if (!ready() || !launchState.classified || launchState.migrationApplied) return
if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
@@ -414,9 +406,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "mobileTitlebarPosition", value)
},
newLayoutDesigns,
newLayoutRequired,
setNewLayoutRequired,
setNewLayoutDesigns(value: boolean) {
if (newLayoutRequired() && !value) return
const next = oldInterfaceRetired() ? true : value
if (newLayoutDesigns() === next) return
if (preferredNewLayout() === next) return
setStore("general", "newLayoutDesigns", next)
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
},
+38 -63
View File
@@ -149,7 +149,6 @@ function createWorkspaceTerminalSession(
scope: ServerScopeValue,
legacySessionID?: string,
) {
const location = { directory: sdk.directory }
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
const [store, setStore, _, ready] = persisted(
@@ -241,63 +240,47 @@ function createWorkspaceTerminalSession(
})
onCleanup(unsub)
const update = (pty: Partial<LocalPTY> & { id: string }) => {
const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
const index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty }))
}
const doUpdate = async () => {
if ((await sdk.protocol) === "v1") {
await sdk.client.pty.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
} else {
await sdk.api.pty.update({
ptyID: pty.id,
location,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
}
}
doUpdate().catch((error: unknown) => {
if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error("Failed to update terminal", error)
})
client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((error: unknown) => {
if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error("Failed to update terminal", error)
})
}
const clone = async (id: string) => {
const clone = async (client: DirectorySDK["client"], id: string) => {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const data = await (async () => {
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.create({ title: pty.title })).data
}
return (
await sdk.api.pty.create({
location,
title: pty.title,
})
).data
})().catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
if (!data?.id) return
const next = await client.pty
.create({
title: pty.title,
})
.catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
if (!next?.data) return
const active = store.active === pty.id
batch(() => {
setStore("all", index, {
id: data.id,
title: data.title ?? pty.title,
id: next.data.id,
title: next.data.title ?? pty.title,
titleNumber: pty.titleNumber,
buffer: undefined,
cursor: undefined,
@@ -306,7 +289,7 @@ function createWorkspaceTerminalSession(
cols: undefined,
})
if (active) {
setStore("active", data.id)
setStore("active", next.data.id)
}
})
}
@@ -325,22 +308,17 @@ function createWorkspaceTerminalSession(
const nextNumber = pickNextTerminalNumber()
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
const doCreate = async () => {
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.create({ title: defaultTitle(nextNumber) })).data
}
return (await sdk.api.pty.create({ location, title: defaultTitle(nextNumber) })).data
}
doCreate()
.then((data) => {
const id = data?.id
sdk.client.pty
.create({ title: defaultTitle(nextNumber) })
.then((pty: { data?: { id?: string; title?: string } }) => {
const id = pty.data?.id
if (!id) {
if (focusRequest !== undefined) cancelFocus(focusRequest)
return
}
const newTerminal = {
id,
title: data?.title ?? defaultTitle(nextNumber),
title: pty.data?.title ?? defaultTitle(nextNumber),
titleNumber: nextNumber,
}
batch(() => {
@@ -357,7 +335,7 @@ function createWorkspaceTerminalSession(
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
update(pty)
update(sdk.client, pty)
},
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
@@ -372,9 +350,10 @@ function createWorkspaceTerminalSession(
})
},
async clone(id: string) {
await clone(id)
await clone(sdk.client, id)
},
bind() {
const client = sdk.client
return {
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
@@ -382,10 +361,10 @@ function createWorkspaceTerminalSession(
setStore("all", index, (pty) => trimTerminal(pty))
},
update(pty: Partial<LocalPTY> & { id: string }) {
update(pty)
update(client, pty)
},
async clone(id: string) {
await clone(id)
await clone(client, id)
},
}
},
@@ -433,11 +412,7 @@ function createWorkspaceTerminalSession(
})
}
const removePromise =
(await sdk.protocol) === "v1"
? sdk.client.pty.remove({ ptyID: id })
: sdk.api.pty.remove({ ptyID: id, location })
await removePromise.catch((error: unknown) => {
await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => {
console.error("Failed to close terminal", error)
})
},
+4 -6
View File
@@ -2,7 +2,7 @@ import { useServerSync } from "@/context/server-sync"
import { decode64 } from "@/utils/base64"
import { useParams } from "@solidjs/router"
import { Iterable, pipe } from "effect"
import { createEffect, createMemo, type Accessor } from "solid-js"
import type { Accessor } from "solid-js"
import { selectProviderCatalog } from "./provider-catalog"
export const popularProviders = [
@@ -17,14 +17,14 @@ export const popularProviders = [
]
const popularProviderSet = new Set(popularProviders)
export function useProviders(directory: Accessor<string | undefined>) {
export function useProviders(directory?: Accessor<string | undefined>) {
const serverSync = useServerSync()
const params = useParams()
const dir = () => (directory ? directory() : decode64(params.dir))
const providers = () => {
const value = dir()
const projectStore = value ? serverSync().child(value)[0] : undefined
if (value)
if (directory)
return selectProviderCatalog({
explicit: true,
directory: value,
@@ -37,7 +37,6 @@ export function useProviders(directory: Accessor<string | undefined>) {
global: serverSync().data.provider,
})
}
return {
all: () => providers().all,
default: () => providers().default,
@@ -59,7 +58,7 @@ export function useProviders(directory: Accessor<string | undefined>) {
},
paid: () => {
const connected = new Set(providers().connected)
const paid = [
return [
...Iterable.filter(
providers().all,
([id]) =>
@@ -67,7 +66,6 @@ export function useProviders(directory: Accessor<string | undefined>) {
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
),
]
return paid
},
}
}
@@ -19,7 +19,7 @@ test("archiving a Home session removes its open titlebar tab", async () => {
await archiveHomeSession({
server: remote,
session: { id: "ses_1", directory: "/workspace" },
archive: async () => undefined,
update: async () => undefined,
remove: () => {
removed = true
},
@@ -37,7 +37,7 @@ test("reports archive failures without removing the session", async () => {
await archiveHomeSession({
server: remote,
session: { id: "ses_1", directory: "/workspace" },
archive: async () => Promise.reject(failure),
update: async () => Promise.reject(failure),
remove: () => {
removed = true
},
+12 -2
View File
@@ -6,15 +6,25 @@ type HomeSession = {
directory: string
}
type SessionUpdate = {
directory: string
sessionID: string
time: { archived: number }
}
export async function archiveHomeSession(input: {
server: ServerConnection.Key
session: HomeSession
archive: (sessionID: string) => Promise<unknown>
update: (value: SessionUpdate) => Promise<unknown>
remove: () => void
onError?: (error: unknown) => void
}) {
await input
.archive(input.session.id)
.update({
directory: input.session.directory,
sessionID: input.session.id,
time: { archived: Date.now() },
})
.then(() => {
input.remove()
notifySessionTabsRemoved({
+86 -33
View File
@@ -1,47 +1,100 @@
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { createHomeController } from "./home/home-controller"
import { createHomeProjectsController } from "./home/home-projects-controller"
import { HomeUtilityNav } from "./home/home-projects-view"
import { HomeProjects } from "./home/home-projects"
import { createHomeScrollController } from "./home/home-scroll-controller"
import { createHomeSessionSearchController } from "./home/home-session-search-controller"
import { HomeProjectsView, HomeUtilityNav } from "./home/home-projects-view"
import { createHomeSessionsController } from "./home/home-sessions-controller"
import { HomeSessions } from "./home/home-sessions"
import { HomeSessionsView } from "./home/home-sessions-view"
export function NewHome() {
const home = createHomeController()
const projects = createHomeProjectsController(home)
const sessions = createHomeSessionsController(home)
const search = createHomeSessionSearchController(home, sessions)
const scroll = createHomeScrollController(sessions.data.groups)
const projects = createHomeProjectsController()
const sessions = createHomeSessionsController(projects)
return (
<div
class={`
m-2 min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
`}
>
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<ScrollView
class="h-full [container-type:size]"
thumbContainer={scroll.viewport.thumbTrack}
thumbHoverTarget={scroll.viewport.hoverTarget}
viewportRef={scroll.viewport.setViewport}
onScroll={(event) => scroll.viewport.update(event.currentTarget.scrollTop)}
onWheel={scroll.viewport.containOuterWheel}
thumbContainer={sessions.scroll.thumbTrack}
thumbHoverTarget={sessions.scroll.hoverTarget}
viewportRef={sessions.scroll.setViewport}
onScroll={(event) => sessions.scroll.update(event.currentTarget.scrollTop)}
onWheel={sessions.scroll.containOuterWheel}
>
<div
class={`
mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3
lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6
`}
>
<HomeProjects projects={projects} scroll={scroll} />
<HomeSessions sessions={sessions} search={search} scroll={scroll} />
<div class="mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
<HomeProjectsView
language={projects.language}
servers={projects.servers}
projects={projects.projects}
recentlyClosed={projects.recentlyClosed}
selection={projects.selection}
homedir={projects.homedir}
serverHealth={projects.serverHealth}
projectsForServer={projects.projectsForServer}
collapsed={projects.collapsed}
menuOpen={projects.menuOpen}
canDefaultServer={projects.canDefaultServer}
isDefaultServer={projects.isDefaultServer}
canRevealProject={projects.canRevealProject}
fileManagerActionLabel={projects.fileManagerActionLabel}
unseenCount={projects.unseenCount}
serverMenuID={projects.serverMenuID}
projectMenuID={projects.projectMenuID}
onWheel={sessions.scroll.containWheel}
onChooseProject={projects.chooseProject}
onFocusServer={projects.focusServer}
onToggleCollapsed={projects.toggleCollapsed}
onEditServer={projects.openEditServer}
onSetDefaultServer={projects.setDefaultServer}
onRemoveServer={projects.removeServer}
onSetMenuOpen={projects.setMenuOpen}
onMoveProject={projects.moveProject}
onSelectProject={projects.selectProject}
onAddProjects={projects.addProjects}
onOpenProjectNewSession={projects.openProjectNewSession}
onEditProject={projects.editProject}
onRevealProject={projects.revealProject}
onClearNotifications={projects.clearNotifications}
onCloseProject={projects.closeProject}
onOpenSettings={projects.openSettings}
onOpenHelp={projects.openHelp}
/>
<HomeSessionsView
language={sessions.language}
groups={sessions.groups}
loading={sessions.loading}
showProjectName={sessions.showProjectName}
server={sessions.server}
canCreateSession={sessions.canCreateSession}
searchValue={sessions.search.value}
searchPlaceholder={sessions.search.placeholder}
searchOpen={sessions.search.open}
searchLoading={sessions.search.loading}
searchResults={sessions.search.results}
searchActive={sessions.search.active}
searchNoResultsLabel={sessions.search.noResultsLabel}
titleOpacity={sessions.header.titleOpacity}
isOpenTab={sessions.hasOpenTab}
onCreateSession={sessions.createSession}
onOpenSession={sessions.openSession}
onArchiveSession={sessions.archiveSession}
onSetHoverTarget={sessions.scroll.setHoverTarget}
onSetThumbTrack={sessions.scroll.setThumbTrack}
onSetContent={sessions.header.setContent}
onSetHeader={sessions.header.setHeader}
onWheel={sessions.scroll.containWheel}
onSetSearchRoot={sessions.search.setRoot}
onSetSearchInput={sessions.search.setInput}
onSetSearchList={sessions.search.setList}
onSearchFocus={sessions.search.focus}
onSearchInput={sessions.search.input}
onSearchClose={sessions.search.close}
onSearchMove={sessions.search.move}
onSearchSelectActive={sessions.search.selectActive}
onSearchHighlight={sessions.search.highlight}
onSearchSelect={sessions.search.select}
/>
<HomeUtilityNav
class="flex lg:hidden"
onOpenSettings={projects.utility.settings}
onOpenHelp={projects.utility.help}
language={projects.copy.language}
onOpenSettings={projects.openSettings}
onOpenHelp={projects.openHelp}
language={projects.language}
/>
</div>
</ScrollView>
@@ -1,108 +0,0 @@
import { useGlobal } from "@/context/global"
import { type HomeProjectSelection, useLayout } from "@/context/layout"
import { ServerConnection, useServer } from "@/context/server"
import { useServerSync } from "@/context/server-sync"
import { useTabs } from "@/context/tabs"
import { toggleHomeProjectSelection } from "@/pages/layout/helpers"
import { createEffect, createMemo } from "solid-js"
export function createHomeController() {
const sync = useServerSync()
const layout = useLayout()
const server = useServer()
const global = useGlobal()
const tabs = useTabs()
const selection = layout.home.selection
const focusedServer = createMemo(
() => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
)
const focusedServerCtx = createMemo(() => {
const conn = focusedServer()
if (!conn) return undefined
return global.ensureServerCtx(conn)
})
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
const recentlyClosed = createMemo(
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
)
const homedir = createMemo(() => focusedSync().data.path.home ?? "")
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
const newSessionProject = createMemo(
() =>
selectedProject() ??
projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ??
projects()[0],
)
createEffect(() => {
const list = global.servers.list()
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
})
function setSelection(next: HomeProjectSelection) {
layout.home.setSelection(next)
}
function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
const ctx = global.ensureServerCtx(conn)
ctx.projects.open(directory)
ctx.projects.touch(directory)
void tabs.newDraft({ server: ServerConnection.key(conn), directory })
}
return {
selection: {
value: selection,
set: setSelection,
focusServer: (conn: ServerConnection.Any) => setSelection({ server: ServerConnection.key(conn) }),
},
server: {
list: global.servers.list,
health: (conn: ServerConnection.Any) => global.servers.health[ServerConnection.key(conn)],
context: (conn: ServerConnection.Any) => global.ensureServerCtx(conn),
focused: focusedServer,
focusedContext: focusedServerCtx,
focusedSync,
},
project: {
list: projects,
recentlyClosed,
homedir,
selected: selectedProject,
newSession: newSessionProject,
forServer: (conn: ServerConnection.Any) => global.ensureServerCtx(conn).projects.list(),
select: (conn: ServerConnection.Any, directory: string) => {
const key = ServerConnection.key(conn)
if (global.servers.health[key]?.healthy === false) return
if (
!global
.ensureServerCtx(conn)
.projects.list()
.some((project) => project.worktree === directory)
)
return
setSelection(toggleHomeProjectSelection(selection(), key, directory))
},
add: (conn: ServerConnection.Any, directories: string[]) => {
const directory = directories[0]
if (!directory) return
const ctx = global.ensureServerCtx(conn)
directories.forEach((item) => ctx.projects.open(item))
ctx.projects.touch(directory)
setSelection({ server: ServerConnection.key(conn), directory })
},
openNewSession: () => {
const conn = focusedServer()
const project = newSessionProject()
if (!conn || !project) return
openProjectNewSession(conn, project.worktree)
},
openProjectNewSession,
},
}
}
export type HomeController = ReturnType<typeof createHomeController>
@@ -2,27 +2,41 @@ import { useDirectoryPicker } from "@/components/directory-picker"
import { useServerManagementController } from "@/components/dialog-select-server"
import { useSettingsCommand } from "@/components/settings-dialog"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { type LocalProject } from "@/context/layout"
import { useGlobal } from "@/context/global"
import { type HomeProjectSelection, type LocalProject, useLayout } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/server"
import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/pages/layout/helpers"
import { ServerConnection, useServer } from "@/context/server"
import { useServerSync } from "@/context/server-sync"
import { useTabs } from "@/context/tabs"
import {
closeHomeProject,
errorMessage,
homeProjectDirectories,
toggleHomeProjectSelection,
} from "@/pages/layout/helpers"
import { fileManagerApp } from "@/utils/file-manager"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@/utils/toast"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createResource } from "solid-js"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller"
export function createHomeProjectsController(home: HomeController) {
export function createHomeProjectsController() {
const sync = useServerSync()
const layout = useLayout()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const dialog = useDialog()
const server = useServer()
const language = useLanguage()
const global = useGlobal()
const tabs = useTabs()
const notification = useNotification()
const openSettings = useSettingsCommand()
const serverManagement = useServerManagementController({ navigateOnAdd: false })
const [menu, setMenu] = createStore({ open: undefined as string | undefined })
const [_state, setState, _, ready] = persisted(
Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record<string, boolean> }),
@@ -32,96 +46,219 @@ export function createHomeProjectsController(home: HomeController) {
(promise) => promise.then(() => _state),
{ initialValue: _state },
)
const selection = layout.home.selection
const focusedServer = createMemo(
() => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
)
const focusedServerCtx = createMemo(() => {
const conn = focusedServer()
if (!conn) return undefined
return global.ensureServerCtx(conn)
})
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
const recentlyClosed = createMemo(
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
)
const homedir = createMemo(() => focusedSync().data.path.home ?? "")
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
const newSessionProject = createMemo(
() =>
selectedProject() ??
projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ??
projects()[0],
)
createEffect(() => {
const id = menu.open
if (!id) return
const connections = global.servers.list()
const valid = connections.some((conn) => {
if (serverMenuID(conn) === id) return true
if (
connections.length > 1 &&
(global.servers.health[ServerConnection.key(conn)]?.healthy !== true || collapsed(conn))
)
return false
const list = connections.length === 1 ? projects() : projectsForServer(conn)
return list.some((project) => projectMenuID(conn, project.worktree) === id)
})
if (!valid) setMenu("open", undefined)
})
createEffect(() => {
const list = global.servers.list()
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
})
function setSelection(next: HomeProjectSelection) {
layout.home.setSelection(next)
}
function focusServer(conn: ServerConnection.Any) {
setSelection({ server: ServerConnection.key(conn) })
}
function selectProject(conn: ServerConnection.Any, directory: string) {
const key = ServerConnection.key(conn)
if (global.servers.health[key]?.healthy === false) return
if (
!global
.ensureServerCtx(conn)
.projects.list()
.some((project) => project.worktree === directory)
)
return
setSelection(toggleHomeProjectSelection(selection(), key, directory))
}
function addProjects(conn: ServerConnection.Any, directories: string[]) {
const directory = directories[0]
if (!directory) return
const ctx = global.ensureServerCtx(conn)
directories.forEach((item) => ctx.projects.open(item))
ctx.projects.touch(directory)
setSelection({ server: ServerConnection.key(conn), directory })
}
function openNewSession() {
const conn = focusedServer()
const project = newSessionProject()
if (!conn || !project) return
openProjectNewSession(conn, project.worktree)
}
function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
const ctx = global.ensureServerCtx(conn)
ctx.projects.open(directory)
ctx.projects.touch(directory)
void tabs.newDraft({ server: ServerConnection.key(conn), directory })
}
function editProject(conn: ServerConnection.Any, project: LocalProject) {
void import("@/components/dialog-edit-project-v2").then(({ DialogEditProjectV2 }) => {
void dialog.show(() => <DialogEditProjectV2 server={conn} project={project} />)
})
}
function directories(project: LocalProject) {
return [project.worktree, ...(project.sandboxes ?? [])]
}
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
const state = notification.ensureServerState(ServerConnection.key(conn))
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
}
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
const state = notification.ensureServerState(ServerConnection.key(conn))
directories(project)
.filter((directory) => state.project.unseenCount(directory) > 0)
.forEach((directory) => state.project.markViewed(directory))
}
function chooseProject(conn: ServerConnection.Any) {
if (global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: (result) => addProjects(conn, homeProjectDirectories(result)),
})
}
function closeProject(conn: ServerConnection.Any, directory: string) {
const next = closeHomeProject(
selection(),
ServerConnection.key(conn),
global.ensureServerCtx(conn).projects,
directory,
)
if (next) setSelection(next)
}
function moveProject(conn: ServerConnection.Any, worktree: string, index: number) {
global.ensureServerCtx(conn).projects.move(worktree, index)
}
function revealProject(conn: ServerConnection.Any, project: LocalProject) {
if (!platform.openPath || !canRevealProject(conn)) return
platform.openPath(project.worktree).catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
}
function canRevealProject(conn: ServerConnection.Any) {
return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn)
}
function projectsForServer(conn: ServerConnection.Any) {
return global.ensureServerCtx(conn).projects.list()
}
function collapsed(conn: ServerConnection.Any) {
return state().collapsed[ServerConnection.key(conn)] ?? false
}
function serverMenuID(conn: ServerConnection.Any) {
return `server:${ServerConnection.key(conn)}`
}
function projectMenuID(conn: ServerConnection.Any, directory: string) {
return `project:${ServerConnection.key(conn)}:${directory}`
}
return {
copy: {
language,
},
selection: {
value: home.selection.value,
},
server: {
list: home.server.list,
health: home.server.health,
projects: home.project.forServer,
collapsed: (conn: ServerConnection.Any) => state().collapsed[ServerConnection.key(conn)] ?? false,
toggleCollapsed: (conn: ServerConnection.Any) => {
const key = ServerConnection.key(conn)
setState("collapsed", key, !state().collapsed[key])
},
canDefault: serverManagement.canDefault,
defaultKey: serverManagement.defaultKey,
setDefault: (conn: ServerConnection.Any | undefined) =>
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
focus: home.selection.focusServer,
},
project: {
list: home.project.list,
recentlyClosed: home.project.recentlyClosed,
homedir: home.project.homedir,
select: home.project.select,
add: home.project.add,
openNewSession: home.project.openProjectNewSession,
edit: (conn: ServerConnection.Any, project: LocalProject) => {
void import("@/components/dialog-edit-project-v2").then(({ DialogEditProjectV2 }) => {
void dialog.show(() => <DialogEditProjectV2 server={conn} project={project} />)
})
},
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
const state = notification.ensureServerState(ServerConnection.key(conn))
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
},
clearNotifications: (conn: ServerConnection.Any, project: LocalProject) => {
const state = notification.ensureServerState(ServerConnection.key(conn))
directories(project)
.filter((directory) => state.project.unseenCount(directory) > 0)
.forEach((directory) => state.project.markViewed(directory))
},
choose: (conn: ServerConnection.Any) => {
if (home.server.health(conn)?.healthy === false) return
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)),
})
},
close: (conn: ServerConnection.Any, directory: string) => {
const next = closeHomeProject(
home.selection.value(),
ServerConnection.key(conn),
home.server.context(conn).projects,
directory,
)
if (next) home.selection.set(next)
},
move: (conn: ServerConnection.Any, worktree: string, index: number) => {
home.server.context(conn).projects.move(worktree, index)
},
canReveal: canRevealProject,
reveal: (conn: ServerConnection.Any, project: LocalProject) => {
if (!platform.openPath || !canRevealProject(conn)) return
platform.openPath(project.worktree).catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
},
},
utility: {
settings: openSettings,
help: () => platform.openLink("https://opencode.ai/desktop-feedback"),
language,
selection,
focusedServer,
focusedServerCtx,
focusedSync,
projects,
recentlyClosed,
homedir,
selectedProject,
newSessionProject,
servers: global.servers.list,
serverHealth: (conn: ServerConnection.Any) => global.servers.health[ServerConnection.key(conn)],
projectsForServer,
collapsed,
toggleCollapsed: (conn: ServerConnection.Any) => {
const key = ServerConnection.key(conn)
setState("collapsed", key, !state().collapsed[key])
},
menuOpen: (id: string) => menu.open === id,
setMenuOpen: (id: string, open: boolean) => setMenu("open", open ? id : undefined),
serverMenuID,
projectMenuID,
canDefaultServer: serverManagement.canDefault,
isDefaultServer: (conn: ServerConnection.Any) => serverManagement.defaultKey() === ServerConnection.key(conn),
setDefaultServer: (conn: ServerConnection.Any | undefined) =>
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
removeServer: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
openEditServer: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
focusServer,
selectProject,
addProjects,
openNewSession,
openProjectNewSession,
editProject,
unseenCount,
clearNotifications,
chooseProject,
closeProject,
moveProject,
canRevealProject,
revealProject,
fileManagerActionLabel: () =>
language.t(fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown").actionLabel),
openSettings,
openHelp: () => platform.openLink("https://opencode.ai/desktop-feedback"),
}
}
@@ -1,5 +1,4 @@
import { type Accessor, createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
import { createStore } from "solid-js/store"
import { type Accessor, createMemo, For, Show } from "solid-js"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
@@ -14,18 +13,15 @@ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerRowMenuView, serverMenuLabels } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health"
import { fileManagerApp } from "@/utils/file-manager"
const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
const serverContextMenuID = (server: ServerConnection.Any) => `server:${ServerConnection.key(server)}`
const projectContextMenuID = (server: ServerConnection.Any, directory: string) =>
`project:${ServerConnection.key(server)}:${directory}`
const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]`
export type HomeProjectsViewProps = {
language: ReturnType<typeof useLanguage>
@@ -37,10 +33,14 @@ export type HomeProjectsViewProps = {
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
collapsed: (server: ServerConnection.Any) => boolean
menuOpen: (id: string) => boolean
canDefaultServer: Accessor<boolean>
defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
isDefaultServer: (server: ServerConnection.Any) => boolean
canRevealProject: (server: ServerConnection.Any) => boolean
fileManagerActionLabel: Accessor<string>
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
serverMenuID: (server: ServerConnection.Any) => string
projectMenuID: (server: ServerConnection.Any, directory: string) => string
onWheel: (event: WheelEvent) => void
onChooseProject: (server: ServerConnection.Any) => void
onFocusServer: (server: ServerConnection.Any) => void
@@ -48,6 +48,7 @@ export type HomeProjectsViewProps = {
onEditServer: (server: ServerConnection.Http) => void
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
onRemoveServer: (server: ServerConnection.Any) => void
onSetMenuOpen: (id: string, open: boolean) => void
onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void
onSelectProject: (server: ServerConnection.Any, directory: string) => void
onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
@@ -61,17 +62,9 @@ export type HomeProjectsViewProps = {
}
export function HomeProjectsView(props: HomeProjectsViewProps) {
const [contextMenu, setContextMenu] = createStore({ open: undefined as string | undefined })
const contextMenuProps = {
contextMenuOpen: (id: string) => contextMenu.open === id,
onSetContextMenuOpen: (id: string, open: boolean) => setContextMenu("open", open ? id : undefined),
}
return (
<aside
class={`
mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden
lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]
`}
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]"
aria-label={props.language.t("home.projects")}
onWheel={(event) => {
if (event.target === event.currentTarget) return
@@ -106,12 +99,7 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
when={props.projects().length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers()[0]} items={props.recentlyClosed()} />}
>
<HomeProjectList
{...props}
{...contextMenuProps}
server={props.servers()[0]}
items={props.projects()}
/>
<HomeProjectList {...props} server={props.servers()[0]} items={props.projects()} />
</Show>
</div>
}
@@ -128,14 +116,13 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
<HomeServerRow
server={item}
{...props}
{...contextMenuProps}
selected={props.selection().server === ServerConnection.key(item) && !props.selection().directory}
collapsed={collapsed()}
health={props.serverHealth(item)}
/>
<Show when={healthy() && hasProjects() && !collapsed()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} {...contextMenuProps} server={item} items={projects()} />
<HomeProjectList {...props} server={item} items={projects()} />
</Show>
</div>
)
@@ -162,22 +149,22 @@ export function HomeUtilityNav(props: {
}) {
return (
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
<HomeProjectNavButton
<button
type="button"
class="text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.onOpenSettings}
>
<IconV2 name="settings-gear" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
</HomeProjectNavButton>
<HomeProjectNavButton
</button>
<button
type="button"
class="text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.onOpenHelp}
>
<IconV2 name="help" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
</HomeProjectNavButton>
</button>
</div>
)
}
@@ -185,15 +172,16 @@ export function HomeUtilityNav(props: {
function HomeServerRow(props: {
language: HomeProjectsViewProps["language"]
projectsForServer: HomeProjectsViewProps["projectsForServer"]
contextMenuOpen: HomeProjectsContextMenuProps["contextMenuOpen"]
menuOpen: HomeProjectsViewProps["menuOpen"]
canDefaultServer: HomeProjectsViewProps["canDefaultServer"]
defaultServerKey: HomeProjectsViewProps["defaultServerKey"]
isDefaultServer: HomeProjectsViewProps["isDefaultServer"]
serverMenuID: HomeProjectsViewProps["serverMenuID"]
onFocusServer: HomeProjectsViewProps["onFocusServer"]
onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"]
onEditServer: HomeProjectsViewProps["onEditServer"]
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"]
onSetMenuOpen: HomeProjectsViewProps["onSetMenuOpen"]
onChooseProject: HomeProjectsViewProps["onChooseProject"]
server: ServerConnection.Any
selected: boolean
@@ -202,26 +190,19 @@ function HomeServerRow(props: {
}) {
const healthy = () => !!props.health?.healthy
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
const contextMenuID = () => serverContextMenuID(props.server)
onCleanup(() => {
const id = contextMenuID()
if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false)
})
const menuID = () => props.serverMenuID(props.server)
return (
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
<HomeProjectNavButton
<button
type="button"
class="pr-16 disabled:opacity-60"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
data-selected={props.selected ? "" : undefined}
disabled={!healthy()}
onClick={() => props.onFocusServer(props.server)}
>
<span
data-action="home-server-collapse"
class={`
-ml-0.5 -mr-1.5 inline-flex size-5 shrink-0 items-center justify-center
rounded-[4px] text-v2-icon-icon-muted
`}
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted"
classList={{
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
"cursor-default opacity-40": !canToggle(),
@@ -253,36 +234,28 @@ function HomeServerRow(props: {
<span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
<Show when={props.server.label}>
{(label) => (
<span
class={`
shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5
text-[9px] leading-none text-v2-text-text-muted
`}
>
<span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
{label()}
</span>
)}
</Show>
</span>
</HomeProjectNavButton>
</button>
<div
class={`
hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
`}
data-menu={props.contextMenuOpen(contextMenuID())}
class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={props.menuOpen(menuID())}
>
<ServerRowMenuView
server={props.server}
labels={serverMenuLabels(props.language)}
canDefault={props.canDefaultServer()}
isDefault={props.defaultServerKey() === ServerConnection.key(props.server)}
isDefault={props.isDefaultServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)}
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
onRemove={() => props.onRemoveServer(props.server)}
open={props.contextMenuOpen(contextMenuID())}
onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
open={props.menuOpen(menuID())}
onOpenChange={(open) => props.onSetMenuOpen(menuID(), open)}
/>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
<IconButtonV2
@@ -300,17 +273,11 @@ function HomeServerRow(props: {
)
}
type HomeProjectsContextMenuProps = {
contextMenuOpen: (id: string) => boolean
onSetContextMenuOpen: (id: string, open: boolean) => void
type HomeProjectListProps = HomeProjectsViewProps & {
server: ServerConnection.Any
items: LocalProject[]
}
type HomeProjectListProps = HomeProjectsViewProps &
HomeProjectsContextMenuProps & {
server: ServerConnection.Any
items: LocalProject[]
}
function HomeProjectList(props: HomeProjectListProps) {
let listRef!: HTMLDivElement
@@ -391,16 +358,16 @@ function HomeProjectEmpty(
const unreachable = () => props.serverHealth(props.server)?.healthy === false
return (
<div class="flex min-w-0 flex-col gap-1">
<HomeProjectNavButton
<button
type="button"
data-action="home-add-project-row"
class="disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
disabled={unreachable()}
onClick={() => props.onChooseProject(props.server)}
>
<IconV2 name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</HomeProjectNavButton>
</button>
<Show when={props.items.length > 0}>
<div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
<div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
@@ -428,32 +395,30 @@ function HomeRecentlyClosedRow(
}
return (
<TooltipV2 placement="right" value={path()}>
<HomeProjectNavButton
<button
type="button"
data-component="home-recently-closed-row"
class="disabled:opacity-60"
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
disabled={unreachable()}
onClick={() => props.onAddProjects(props.server, [props.project.worktree])}
>
<HomeProjectAvatar project={props.project} outline />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</HomeProjectNavButton>
</button>
</TooltipV2>
)
}
function HomeProjectRow(
props: HomeProjectsViewProps &
HomeProjectsContextMenuProps & {
project: LocalProject
server: ServerConnection.Any
index: () => number
serverSelected: boolean
selected: boolean
unseen: number
},
props: HomeProjectsViewProps & {
project: LocalProject
server: ServerConnection.Any
index: () => number
serverSelected: boolean
selected: boolean
unseen: number
},
) {
const platform = usePlatform()
const serverUnreachable = () => props.serverHealth(props.server)?.healthy === false
const sortable = useSortable({
get id() {
@@ -464,24 +429,20 @@ function HomeProjectRow(
},
})
let pointerDownSelected: boolean | undefined
const contextMenuID = () => projectContextMenuID(props.server, props.project.worktree)
onCleanup(() => {
const id = contextMenuID()
if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false)
})
const menuID = () => props.projectMenuID(props.server, props.project.worktree)
return (
<div
ref={sortable.ref}
class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
classList={{ "z-10": sortable.isDragSource() }}
>
<HomeProjectNavButton
<button
type="button"
data-component="home-project-row"
class="pr-16 disabled:opacity-60"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
classList={{
"bg-v2-background-bg-layer-01 text-v2-text-text-base": sortable.isDragSource(),
"[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]": sortable.isDragSource(),
"bg-v2-background-bg-layer-01 text-v2-text-text-base [box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]":
sortable.isDragSource(),
}}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
@@ -516,20 +477,17 @@ function HomeProjectRow(
>
<HomeProjectAvatar project={props.project} />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</HomeProjectNavButton>
</button>
<div
class={`
hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
`}
data-menu={props.contextMenuOpen(contextMenuID())}
class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={props.menuOpen(menuID())}
>
<MenuV2
gutter={6}
modal={false}
placement="bottom-end"
open={props.contextMenuOpen(contextMenuID())}
onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
open={props.menuOpen(menuID())}
onOpenChange={(open) => props.onSetMenuOpen(menuID(), open)}
>
<MenuV2.Trigger
as={IconButtonV2}
@@ -549,10 +507,7 @@ function HomeProjectRow(
</MenuV2.Item>
<Show when={props.canRevealProject(props.server)}>
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
{props.language.t(
fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown")
.actionLabel,
)}
{props.fileManagerActionLabel()}
</MenuV2.Item>
</Show>
<MenuV2.Item
@@ -581,28 +536,6 @@ function HomeProjectRow(
)
}
function HomeProjectNavButton(props: JSX.ButtonHTMLAttributes<HTMLButtonElement>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<button
{...rest}
class={`
flex h-7 min-w-0 w-full shrink-0 cursor-default items-center gap-2 rounded-[6px] bg-transparent px-1.5 text-left
text-v2-text-text-muted [font-weight:440] transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]
data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base
data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03
focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:outline-none
focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]
${local.class ?? ""}
`}
classList={local.classList}
>
{local.children}
</button>
)
}
function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
const name = createMemo(() => displayName(props.project))
return (
@@ -1,40 +0,0 @@
import type { HomeProjectsController } from "./home-projects-controller"
import { HomeProjectsView } from "./home-projects-view"
import type { HomeScrollController } from "./home-scroll-controller"
export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) {
return (
<HomeProjectsView
language={props.projects.copy.language}
servers={props.projects.server.list}
projects={props.projects.project.list}
recentlyClosed={props.projects.project.recentlyClosed}
selection={props.projects.selection.value}
homedir={props.projects.project.homedir}
serverHealth={props.projects.server.health}
projectsForServer={props.projects.server.projects}
collapsed={props.projects.server.collapsed}
canDefaultServer={props.projects.server.canDefault}
defaultServerKey={props.projects.server.defaultKey}
canRevealProject={props.projects.project.canReveal}
unseenCount={props.projects.project.unseenCount}
onWheel={props.scroll.viewport.containWheel}
onChooseProject={props.projects.project.choose}
onFocusServer={props.projects.server.focus}
onToggleCollapsed={props.projects.server.toggleCollapsed}
onEditServer={props.projects.server.edit}
onSetDefaultServer={props.projects.server.setDefault}
onRemoveServer={props.projects.server.remove}
onMoveProject={props.projects.project.move}
onSelectProject={props.projects.project.select}
onAddProjects={props.projects.project.add}
onOpenProjectNewSession={props.projects.project.openNewSession}
onEditProject={props.projects.project.edit}
onRevealProject={props.projects.project.reveal}
onClearNotifications={props.projects.project.clearNotifications}
onCloseProject={props.projects.project.close}
onOpenSettings={props.projects.utility.settings}
onOpenHelp={props.projects.utility.help}
/>
)
}
@@ -1,145 +0,0 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeSessionGroup } from "./home-sessions-controller"
const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
export function createHomeScrollController(groups: Accessor<HomeSessionGroup[]>) {
const [thumbTrack, setThumbTrack] = createSignal<HTMLDivElement>()
const [hoverTarget, setHoverTarget] = createSignal<HTMLElement>()
const [state, setState] = createStore({
titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
})
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
let viewport: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
createEffect(() => {
const items = groups()
const ids = new Set(items.map((group) => group.id))
headerRefs.forEach((_, id) => {
if (!ids.has(id)) headerRefs.delete(id)
})
headerOffsets.forEach((_, id) => {
if (!ids.has(id)) headerOffsets.delete(id)
})
if (items.length === 0) {
content = undefined
bindResizeObserver()
}
queuePositionUpdate()
})
onCleanup(() => {
if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
resizeObserver?.disconnect()
})
function queuePositionUpdate() {
if (typeof requestAnimationFrame === "undefined") {
updatePositionCache()
return
}
if (positionFrame !== undefined) return
positionFrame = requestAnimationFrame(() => {
positionFrame = undefined
updatePositionCache()
})
}
function updatePositionCache() {
if (!viewport) return
const header = groups()
.map((group) => headerRefs.get(group.id))
.find((element) => element !== undefined)
if (header && typeof getComputedStyle === "function") {
const top = Number.parseFloat(getComputedStyle(header).top)
if (Number.isFinite(top)) stickyTop = top
}
groups().forEach((group) => {
const element = headerRefs.get(group.id)
if (element) headerOffsets.set(group.id, element.offsetTop)
})
update(viewport.scrollTop)
}
function update(scrollTop: number) {
const items = groups()
items.forEach((group, index) => {
const nextOffset = items
.slice(index + 1)
.map((item) => headerOffsets.get(item.id))
.find((offset) => offset !== undefined)
const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
const opacity =
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
})
}
function bindResizeObserver() {
resizeObserver?.disconnect()
if (typeof ResizeObserver === "undefined") return
resizeObserver = new ResizeObserver(queuePositionUpdate)
if (viewport) resizeObserver.observe(viewport)
if (content) resizeObserver.observe(content)
}
function containWheel(event: WheelEvent) {
if (!viewport) return
if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
if (!(event.target instanceof Element)) return
const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
if (
scrollable !== viewport &&
scrollable &&
(event.deltaY < 0
? scrollable.scrollTop > 0
: scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
)
return
event.preventDefault()
}
return {
viewport: {
thumbTrack,
hoverTarget,
setThumbTrack,
setHoverTarget,
setViewport: (element: HTMLDivElement) => {
viewport = element
bindResizeObserver()
queuePositionUpdate()
},
update,
containWheel,
containOuterWheel: (event: WheelEvent) => {
if (!viewport) return
if (event.target instanceof Node && viewport.contains(event.target)) return
containWheel(event)
},
},
header: {
setContent: (element: HTMLDivElement) => {
content = element
bindResizeObserver()
queuePositionUpdate()
},
setHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => {
headerRefs.set(id, element)
queuePositionUpdate()
},
titleOpacity: (id: HomeSessionGroup["id"]) => state.titleOpacity[id] ?? 1,
},
}
}
export type HomeScrollController = ReturnType<typeof createHomeScrollController>
@@ -1,114 +0,0 @@
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { serverName } from "@/context/server"
import { displayName } from "@/pages/layout/helpers"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller"
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller"
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
export function createHomeSessionSearchController(home: HomeController, sessions: HomeSessionSearchSource) {
const command = useCommand()
const language = useLanguage()
const [state, setState] = createStore({ value: "", focused: false, highlighted: "" })
let root: HTMLDivElement | undefined
let input: HTMLInputElement | undefined
let list: HTMLDivElement | undefined
const query = createMemo(() => state.value.trim())
const results = createMemo(() => {
const value = query().toLowerCase()
if (!value) return []
return sessions.data
.searchRecords()
.filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
})
const active = createMemo(() => {
const records = results()
if (records.some((record) => homeSessionSearchKey(record) === state.highlighted)) return state.highlighted
return records[0] ? homeSessionSearchKey(records[0]) : ""
})
const open = createMemo(() => state.focused && query().length > 0)
const placeholder = createMemo(() => {
const project = home.project.selected()
if (project) return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) })
if (home.server.list().length > 1) {
const conn = home.server.focused()
if (conn) return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) })
}
return language.t("home.sessions.search.placeholder")
})
onCleanup(
makeEventListener(document, "pointerdown", (event) => {
if (!open()) return
const target = event.target
if (!(target instanceof Node) || root?.contains(target)) return
close()
}),
)
command.register("home.search", () => [
{
id: "home.sessions.search.focus",
title: placeholder(),
keybind: "mod+f",
hidden: true,
onSelect: focus,
},
])
function focus() {
input?.focus()
setState("focused", true)
}
function close() {
setState({ value: "", focused: false })
}
function select(record: HomeSessionRecord, options?: { background?: boolean }) {
sessions.session.open(record.session, options)
if (!options?.background) close()
}
return {
query: {
value: () => state.value,
placeholder,
open,
focus,
input: (value: string) => setState({ value, highlighted: "" }),
close,
},
result: {
loading: sessions.data.loading,
list: results,
active,
noResultsLabel: () => language.t("home.sessions.search.noResults", { query: query() }),
highlight: (record: HomeSessionRecord) => setState("highlighted", homeSessionSearchKey(record)),
move: (delta: number) => {
const records = results()
if (records.length === 0) return
const index = records.findIndex((record) => homeSessionSearchKey(record) === active())
const next = ((index === -1 ? 0 : index) + delta + records.length) % records.length
setState("highlighted", homeSessionSearchKey(records[next]))
list?.querySelector<HTMLElement>(`[data-key="${state.highlighted}"]`)?.scrollIntoView({ block: "nearest" })
},
select,
selectActive: () => {
const record = results().find((item) => homeSessionSearchKey(item) === active())
if (record) select(record)
},
},
element: {
setRoot: (element: HTMLDivElement) => (root = element),
setInput: (element: HTMLInputElement) => (input = element),
setList: (element: HTMLDivElement) => (list = element),
},
}
}
export type HomeSessionSearchController = ReturnType<typeof createHomeSessionSearchController>
@@ -2,10 +2,21 @@ import type { Session } from "@opencode-ai/sdk/v2/client"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMarked } from "@opencode-ai/ui/context/marked"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useQuery } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js"
import { produce } from "solid-js/store"
import {
type Accessor,
createEffect,
createMemo,
createRoot,
createSignal,
type JSX,
on,
onCleanup,
startTransition,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useCommand } from "@/context/command"
import {
loadHomeSessionIndex,
@@ -14,7 +25,7 @@ import {
} from "@/context/global-sync/home-session-index"
import type { LocalProject } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { ServerConnection, serverName } from "@/context/server"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
@@ -22,9 +33,13 @@ import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast"
import { Binary } from "@opencode-ai/core/util/binary"
import { archiveHomeSession } from "../home-session-archive"
import type { HomeController } from "./home-controller"
import type { HomeProjectsController } from "./home-projects-controller"
const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
export type HomeSessionRecord = {
session: Session
project: LocalProject
@@ -39,21 +54,29 @@ export type HomeSessionGroup = {
export type OpenSessionOptions = { background?: boolean }
export function createHomeSessionsController(home: HomeController) {
export function createHomeSessionsController(projects: HomeProjectsController) {
const tabs = useTabs()
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const marked = useMarked()
const [state, setState] = createStore({ search: "", searchFocused: false, active: "" })
const [thumbTrack, setThumbTrack] = createSignal<HTMLDivElement>()
const [hoverTarget, setHoverTarget] = createSignal<HTMLElement>()
let viewport: HTMLDivElement | undefined
let searchRoot: HTMLDivElement | undefined
let searchInput: HTMLInputElement | undefined
let searchList: HTMLDivElement | undefined
const search = createMemo(() => state.search.trim())
const projectDirectories = createMemo(() => {
const project = home.project.selected()
if (!project) return home.project.list().flatMap(directories)
const project = projects.selectedProject()
if (!project) return projects.projects().flatMap(directories)
return directories(project)
})
const projectByID = createMemo(
() => new Map(home.project.list().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
() => new Map(projects.projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const homeSessions = () => home.server.focusedSync().homeSessions
const homeSessions = () => projects.focusedSync().homeSessions
const sessionEventLoad = useQuery(() => ({
queryKey: homeSessions().eventsKey,
queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
@@ -62,9 +85,9 @@ export function createHomeSessionsController(home: HomeController) {
}))
const sessionLoad = useQuery(() => ({
queryKey: homeSessions().indexKey,
enabled: !!home.server.focusedContext(),
enabled: !!projects.focusedServerCtx(),
queryFn: async ({ signal }) => {
const ctx = home.server.focusedContext()
const ctx = projects.focusedServerCtx()
if (!ctx) return { sessions: [], eventSequence: 0 }
const cache = homeSessions()
const eventSequence = cache.eventSequence()
@@ -92,17 +115,33 @@ export function createHomeSessionsController(home: HomeController) {
buildHomeSessionRecords({
sessions: indexedSessions,
projectDirectories,
projects: home.project.list,
projects: projects.projects,
projectByID,
}),
)
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
const searchResults = createMemo(() => {
const query = search().toLowerCase()
if (!query) return []
return allRecords().filter((record) => matchesHomeSessionSearch(record, query))
})
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
const searchPlaceholder = createMemo(() => {
const project = projects.selectedProject()
if (project) return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) })
if (projects.servers().length > 1) {
const conn = projects.focusedServer()
if (conn) return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) })
}
return language.t("home.sessions.search.placeholder")
})
const groups = createMemo(() => groupSessions(records(), language))
const header = createHomeSessionHeaderController(groups)
const prefetched = new Set<string>()
createEffect(() => {
const ctx = home.server.focusedContext()
const conn = home.server.focused()
const ctx = projects.focusedServerCtx()
const conn = projects.focusedServer()
if (!ctx || !conn) return
records()
.slice(0, 2)
@@ -133,15 +172,31 @@ export function createHomeSessionsController(home: HomeController) {
})
})
command.register("home.palette", () => [
createEffect(() => syncActive(searchResults()))
createEffect(
on(
() => state.search,
() => syncActive(searchResults()),
),
)
onCleanup(
makeEventListener(document, "pointerdown", (event) => {
if (!searchOpen()) return
const target = event.target
if (!(target instanceof Node) || searchRoot?.contains(target)) return
closeSearch()
}),
)
command.register("home", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: async () => {
const conn = home.server.focused()
const conn = projects.focusedServer()
if (!conn) return
const ctx = home.server.focusedContext()
const ctx = projects.focusedServerCtx()
if (!ctx) return
const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
void dialog.show(() => (
@@ -163,82 +218,171 @@ export function createHomeSessionsController(home: HomeController) {
))
},
},
{
id: "home.sessions.search.focus",
title: searchPlaceholder(),
keybind: "mod+f",
hidden: true,
onSelect: focusSearch,
},
])
function syncActive(results: HomeSessionRecord[]) {
if (results.length === 0) {
setState("active", "")
return
}
if (!results.some((record) => homeSessionSearchKey(record) === state.active)) {
setState("active", homeSessionSearchKey(results[0]))
}
}
function focusSearch() {
searchInput?.focus()
setState("searchFocused", true)
}
function closeSearch() {
setState("search", "")
setState("searchFocused", false)
}
function moveActive(delta: number) {
const results = searchResults()
if (results.length === 0) return
const index = results.findIndex((record) => homeSessionSearchKey(record) === state.active)
const next = ((index === -1 ? 0 : index) + delta + results.length) % results.length
setState("active", homeSessionSearchKey(results[next]))
searchList?.querySelector<HTMLElement>(`[data-key="${state.active}"]`)?.scrollIntoView({ block: "nearest" })
}
function selectSearchSession(record: HomeSessionRecord, options?: OpenSessionOptions) {
openSession(record.session, options)
if (!options?.background) closeSearch()
}
function openSession(session: Session, options?: OpenSessionOptions) {
const directoryKey = pathKey(session.directory)
const project =
projects
.projects()
.find(
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
) ?? projectForSession(session, projects.projects(), projectByID())
const conn = projects.focusedServer()
if (!conn) return
const directory = project?.worktree ?? session.directory
const ctx = projects.focusedServerCtx()
if (!ctx) return
ctx.projects.open(directory)
if (options?.background) {
tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
return
}
ctx.projects.touch(directory)
void startTransition(() => {
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
tabs.select(tab)
})
}
async function archiveSession(session: Session) {
const conn = projects.focusedServer()
const ctx = projects.focusedServerCtx()
if (!conn || !ctx) return
const [, setStore] = ctx.sync.child(session.directory)
await archiveHomeSession({
server: ServerConnection.key(conn),
session,
update: (value) => ctx.sdk.client.session.update(value),
remove: () =>
setStore(
produce((draft) => {
const match = Binary.search(draft.session, session.id, (item) => item.id)
if (match.found) draft.session.splice(match.index, 1)
}),
),
onError: (cause) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
})
}
function setViewport(element: HTMLDivElement) {
viewport = element
header.setViewport(element)
}
function containWheel(event: WheelEvent) {
if (!viewport) return
if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
if (!(event.target instanceof Element)) return
const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
if (
scrollable !== viewport &&
scrollable &&
(event.deltaY < 0
? scrollable.scrollTop > 0
: scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
)
return
event.preventDefault()
}
return {
copy: {
language,
language,
records,
groups,
loading: () => sessionLoad.isLoading,
showProjectName: () => !projects.selectedProject(),
server: () => projects.selection().server,
canCreateSession: () => !!projects.newSessionProject(),
createSession: projects.openNewSession,
openSession,
archiveSession,
hasOpenTab: (record: HomeSessionRecord) =>
sessionHasOpenTab(tabs.store, projects.selection().server, record.session),
header,
scroll: {
thumbTrack,
hoverTarget,
setThumbTrack,
setHoverTarget,
setViewport,
update: (scrollTop: number) => header.update(scrollTop),
containWheel,
containOuterWheel: (event: WheelEvent) => {
if (!viewport) return
if (event.target instanceof Node && viewport.contains(event.target)) return
containWheel(event)
},
},
data: {
records,
groups,
search: {
value: () => state.search,
query: search,
placeholder: searchPlaceholder,
open: searchOpen,
loading: () => sessionLoad.isLoading,
searchRecords: allRecords,
},
session: {
showProjectName: () => !home.project.selected(),
server: () => home.selection.value().server,
canCreate: () => !!home.project.newSession(),
create: home.project.openNewSession,
open: (session: Session, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.directory)
const project =
home.project
.list()
.find(
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
) ?? projectForSession(session, home.project.list(), projectByID())
const conn = home.server.focused()
if (!conn) return
const directory = project?.worktree ?? session.directory
const ctx = home.server.focusedContext()
if (!ctx) return
ctx.projects.open(directory)
if (options?.background) {
tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
return
}
ctx.projects.touch(directory)
void startTransition(() => {
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
tabs.select(tab)
})
results: searchResults,
active: () => state.active,
noResultsLabel: () => language.t("home.sessions.search.noResults", { query: search() }),
setRoot: (element: HTMLDivElement) => (searchRoot = element),
setInput: (element: HTMLInputElement) => (searchInput = element),
setList: (element: HTMLDivElement) => (searchList = element),
input: (value: string) => setState("search", value),
focus: focusSearch,
close: closeSearch,
highlight: (record: HomeSessionRecord) => setState("active", homeSessionSearchKey(record)),
move: moveActive,
select: selectSearchSession,
selectActive: () => {
const record = searchResults().find((item) => homeSessionSearchKey(item) === state.active)
if (record) selectSearchSession(record)
},
archive: async (session: Session) => {
const conn = home.server.focused()
const ctx = home.server.focusedContext()
if (!conn || !ctx) return
const [, setStore] = ctx.sync.child(session.directory)
if ((await ctx.sdk.protocol) !== "v1") return
await archiveHomeSession({
server: ServerConnection.key(conn),
session,
archive: (sessionID) =>
ctx.sdk.client.session.update({
sessionID,
directory: session.directory,
time: { archived: Date.now() },
}),
remove: () =>
setStore(
produce((draft) => {
const match = Binary.search(draft.session, session.id, (item) => item.id)
if (match.found) draft.session.splice(match.index, 1)
}),
),
onError: (cause) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
})
},
},
tab: {
isOpen: (record: HomeSessionRecord) =>
sessionHasOpenTab(tabs.store, home.selection.value().server, record.session),
},
}
}
@@ -271,6 +415,10 @@ function buildHomeSessionRecords(input: {
})
}
function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) {
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query)
}
export function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}`
}
@@ -299,6 +447,116 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
].filter((group) => group.sessions.length > 0)
}
function createHomeSessionHeaderController(groups: () => HomeSessionGroup[]) {
let viewport: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
const [state, setState] = createStore({
titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
})
createEffect(() => {
const items = groups()
const ids = new Set(items.map((group) => group.id))
headerRefs.forEach((_, id) => {
if (!ids.has(id)) headerRefs.delete(id)
})
headerOffsets.forEach((_, id) => {
if (!ids.has(id)) headerOffsets.delete(id)
})
if (items.length === 0) {
content = undefined
bindResizeObserver()
}
queuePositionUpdate()
})
onCleanup(() => {
if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
resizeObserver?.disconnect()
})
function setViewport(element: HTMLDivElement) {
viewport = element
bindResizeObserver()
queuePositionUpdate()
}
function setContent(element: HTMLDivElement) {
content = element
bindResizeObserver()
queuePositionUpdate()
}
function setHeader(id: HomeSessionGroup["id"], element: HTMLDivElement) {
headerRefs.set(id, element)
queuePositionUpdate()
}
function queuePositionUpdate() {
if (typeof requestAnimationFrame === "undefined") {
updatePositionCache()
return
}
if (positionFrame !== undefined) return
positionFrame = requestAnimationFrame(() => {
positionFrame = undefined
updatePositionCache()
})
}
function updatePositionCache() {
if (!viewport) return
const header = groups()
.map((group) => headerRefs.get(group.id))
.find((element) => element !== undefined)
if (header && typeof getComputedStyle === "function") {
const top = Number.parseFloat(getComputedStyle(header).top)
if (Number.isFinite(top)) stickyTop = top
}
groups().forEach((group) => {
const element = headerRefs.get(group.id)
if (element) headerOffsets.set(group.id, element.offsetTop)
})
update(viewport.scrollTop)
}
function update(scrollTop: number) {
const items = groups()
items.forEach((group, index) => {
const nextOffset = items
.slice(index + 1)
.map((item) => headerOffsets.get(item.id))
.find((offset) => offset !== undefined)
const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
const opacity =
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
})
}
function bindResizeObserver() {
resizeObserver?.disconnect()
if (typeof ResizeObserver === "undefined") return
resizeObserver = new ResizeObserver(queuePositionUpdate)
if (viewport) resizeObserver.observe(viewport)
if (content) resizeObserver.observe(content)
}
return {
setViewport,
setContent,
setHeader,
update,
titleOpacity: (id: HomeSessionGroup["id"]) => state.titleOpacity[id] ?? 1,
}
}
export type HomeSessionsController = ReturnType<typeof createHomeSessionsController>
export function HomeSessionStatusController(props: {
@@ -20,8 +20,18 @@ import {
} from "./home-sessions-controller"
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
const HOME_ROW = `${HOME_ROW_BASE} [font-weight:530] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover`
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
const HOME_SEARCH_RESULT_ROW =
"flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
const HOME_SEARCH_RESULT_TITLE =
"min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:530]"
const HOME_SEARCH_RESULT_META =
"min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]"
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
// tab in the background without navigating, matching browser conventions.
@@ -183,10 +193,7 @@ function HomeSessionLeading(props: {
<Show when={props.open}>
<span
aria-hidden="true"
class={`
pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2
rounded-[2px] bg-v2-background-bg-layer-04
`}
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
style={{ right: "calc(100% + 4px)" }}
/>
</Show>
@@ -208,10 +215,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
<Show when={props.searchOpen()}>
<div
data-component="home-session-search-panel"
class={`
absolute flex flex-col overflow-hidden rounded-[12px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]
`}
class="absolute flex flex-col overflow-hidden rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
style={{ top: "-6px", left: "-6px", width: "calc(100% + 12px)" }}
>
<div class="flex flex-col pt-9">
@@ -227,23 +231,13 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
<Show
when={props.searchResults().length > 0}
fallback={
<p
class={`
my-1.5 px-4 pb-2 text-[13px] leading-4 tracking-[-0.04px]
text-v2-text-text-muted [font-weight:440]
`}
>
<p class="my-1.5 px-4 pb-2 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
{props.searchNoResultsLabel()}
</p>
}
>
<div class="flex flex-col">
<p
class={`
my-1.5 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px]
text-v2-text-text-muted [font-weight:440]
`}
>
<p class="my-1.5 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
{props.language.t("home.sessions.search.sessions")}
</p>
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
@@ -266,20 +260,11 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
</div>
</div>
</Show>
<label
class={`
relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2
bg-v2-background-bg-layer-02/60 text-v2-icon-icon-muted transition-[background-color,box-shadow]
duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02
`}
>
<label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02">
<IconV2 name="magnifying-glass" />
<input
ref={props.onSetSearchInput}
class={`
relative z-20 min-w-0 flex-1 border-0 bg-transparent outline-0
text-v2-text-text-base [font-weight:440] placeholder:text-v2-text-text-faint
`}
class="relative z-20 min-w-0 flex-1 border-0 bg-transparent text-v2-text-text-base outline-0 [font-weight:440] placeholder:text-v2-text-text-faint"
value={props.searchValue()}
placeholder={props.searchPlaceholder()}
aria-label={props.searchPlaceholder()}
@@ -356,12 +341,8 @@ function HomeSessionSearchResultRow(
data-component="home-session-search-row"
role="option"
aria-selected={props.selected}
class={`
flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left
transition-[background-color] duration-[120ms] ease-in-out
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
`}
classList={{
[HOME_SEARCH_RESULT_ROW]: true,
"bg-v2-overlay-simple-overlay-hover": props.selected,
group: !!showProjectName(),
}}
@@ -383,9 +364,13 @@ function HomeSessionSearchResultRow(
revealProjectOnHover={!!showProjectName()}
/>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} search />
<span
class={`${HOME_SEARCH_RESULT_TITLE} ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
>
{title()}
</span>
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} search />
<span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span>
</Show>
</div>
</button>
@@ -401,11 +386,7 @@ function HomeSessionGroupHeader(props: {
return (
<div
ref={props.onSetRef}
class={`
pointer-events-none sticky top-[84px] flex h-7 min-w-0 items-center justify-between
bg-v2-background-bg-base pl-3 lg:top-[108px]
`}
classList={{ "home-session-group-header z-[5]": !!props.elevated, "z-10": !props.elevated }}
class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
>
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
{props.title}
@@ -426,12 +407,7 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
<button
type="button"
data-component="home-session-row"
class={`
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
`}
class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
@@ -448,18 +424,19 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<span
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
>
{title()}
</span>
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
{props.record.projectName}
</span>
</Show>
</button>
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
<div
class={`
hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1
group-hover/session:opacity-100 focus-within:opacity-100
`}
>
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
<IconButtonV2
data-action="home-session-archive"
@@ -480,49 +457,13 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
)
}
function HomeSessionTitle(props: { title: string; showProjectName: boolean; search?: boolean }) {
return (
<span
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530]"
classList={{
"text-[13px] leading-4 tracking-[-0.04px]": !!props.search,
"max-w-[min(70%,480px)] flex-[0_1_auto]": props.showProjectName,
"flex-[1_1_auto]": !props.showProjectName,
}}
>
{props.title}
</span>
)
}
function HomeSessionProjectName(props: { name: string; search?: boolean }) {
return (
<span
class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]"
classList={{ "text-[13px] leading-4 tracking-[-0.04px]": !!props.search }}
>
{props.name}
</span>
)
}
function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnType<typeof useLanguage> }) {
return (
<div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
<div
class={`
shrink-0 text-[13px] leading-[13px] tracking-[-0.04px]
text-v2-text-text-base [font-weight:530]
`}
>
<div class="shrink-0 text-[13px] leading-[13px] tracking-[-0.04px] text-v2-text-text-base [font-weight:530]">
{props.language.t("home.sessions.empty")}
</div>
<p
class={`
mb-1 text-center text-[13px] leading-5 tracking-[-0.04px]
text-v2-text-text-muted [font-weight:440]
`}
>
<p class="mb-1 text-center text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
{props.language.t("home.sessions.empty.description")}
</p>
<Show when={props.onNewSession}>
@@ -1,48 +0,0 @@
import type { HomeScrollController } from "./home-scroll-controller"
import type { HomeSessionSearchController } from "./home-session-search-controller"
import type { HomeSessionsController } from "./home-sessions-controller"
import { HomeSessionsView } from "./home-sessions-view"
export function HomeSessions(props: {
sessions: HomeSessionsController
search: HomeSessionSearchController
scroll: HomeScrollController
}) {
return (
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups}
loading={props.sessions.data.loading}
showProjectName={props.sessions.session.showProjectName}
server={props.sessions.session.server}
canCreateSession={props.sessions.session.canCreate}
searchValue={props.search.query.value}
searchPlaceholder={props.search.query.placeholder}
searchOpen={props.search.query.open}
searchLoading={props.search.result.loading}
searchResults={props.search.result.list}
searchActive={props.search.result.active}
searchNoResultsLabel={props.search.result.noResultsLabel}
titleOpacity={props.scroll.header.titleOpacity}
isOpenTab={props.sessions.tab.isOpen}
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onArchiveSession={props.sessions.session.archive}
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
onSetContent={props.scroll.header.setContent}
onSetHeader={props.scroll.header.setHeader}
onWheel={props.scroll.viewport.containWheel}
onSetSearchRoot={props.search.element.setRoot}
onSetSearchInput={props.search.element.setInput}
onSetSearchList={props.search.element.setList}
onSearchFocus={props.search.query.focus}
onSearchInput={props.search.query.input}
onSearchClose={props.search.query.close}
onSearchMove={props.search.result.move}
onSearchSelectActive={props.search.result.selectActive}
onSearchHighlight={props.search.result.highlight}
onSearchSelect={props.search.result.select}
/>
)
}
+38 -51
View File
@@ -36,7 +36,6 @@ import { useProviders } from "@/hooks/use-providers"
import { toaster } from "@opencode-ai/ui/toast"
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { useServerSDK } from "@/context/server-sdk"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import { clearWorkspaceTerminals } from "@/context/terminal"
import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
import { useNotification } from "@/context/notification"
@@ -49,7 +48,6 @@ import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
import { listAllSessions } from "@/utils/session"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
@@ -119,7 +117,7 @@ export default function LegacyLayout(props: ParentProps) {
const permission = usePermission()
const navigate = useNavigate()
setNavigate(navigate)
const providers = useProviders(() => undefined)
const providers = useProviders()
const dialog = useDialog()
const command = useCommand()
const theme = useTheme()
@@ -872,15 +870,14 @@ export default function LegacyLayout(props: ParentProps) {
}
async function archiveSession(session: Session) {
if ((await serverSDK().protocol) !== "v1") return
const [store, setStore] = serverSync().child(session.directory)
const sessions = store.session ?? []
const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1]
await serverSDK().client.session.update({
sessionID: session.id,
directory: session.directory,
sessionID: session.id,
time: { archived: Date.now() },
})
setStore(
@@ -1188,12 +1185,9 @@ export default function LegacyLayout(props: ParentProps) {
}
const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target)
const listed = await Promise.resolve(
project?.id ?? serverSDK().api.project.current({ location: { directory: root } }),
)
.then((value) => (typeof value === "string" ? value : value.id))
.then((projectID) => serverSDK().api.project.directories({ projectID, location: { directory: root } }))
.then((items) => items.map((item) => item.directory).filter((item) => pathKey(item) !== pathKey(root)))
const listed = await serverSDK()
.client.worktree.list({ directory: root })
.then((x) => x.data ?? [])
.catch(() => [] as string[])
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
return canOpen(target)
@@ -1237,11 +1231,10 @@ export default function LegacyLayout(props: ParentProps) {
await Promise.all(
dirs.map(async (item) => ({
path: { directory: item },
session: await listAllSessions(serverSDK().api.session, {
directory: item,
parentID: null,
order: "desc",
}).catch(() => []),
session: await serverSDK()
.client.session.list({ directory: item })
.then((x) => x.data ?? [])
.catch(() => []),
})),
),
Date.now(),
@@ -1301,16 +1294,7 @@ export default function LegacyLayout(props: ParentProps) {
const name = next === getFilename(project.worktree) ? "" : next
if (project.id && project.id !== "global") {
const sdk = serverSDK()
if ((await sdk.protocol) !== "v1") return
const result = await sdk.client.project
.update({ projectID: project.id, directory: project.worktree, name })
.then((response) => response.data)
if (!result) return
// const result = await serverSDK().api.project.update({ projectID: project.id, name })
serverSync().set("project", (items) =>
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
)
await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name })
return
}
@@ -1461,7 +1445,10 @@ export default function LegacyLayout(props: ParentProps) {
})
const dismiss = () => toaster.dismiss(progress)
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
const sessions: Session[] = await serverSDK()
.client.session.list({ directory })
.then((x) => x.data ?? [])
.catch(() => [])
clearWorkspaceTerminals(
directory,
@@ -1490,20 +1477,20 @@ export default function LegacyLayout(props: ParentProps) {
return
}
if ((await serverSDK().protocol) === "v1")
await Promise.all(
sessions
.filter((session) => session.time.archived === undefined)
.map((session) =>
serverSDK()
.client.session.update({
sessionID: session.id,
directory: session.directory,
time: { archived: Date.now() },
})
.catch(() => undefined),
),
)
const archivedAt = Date.now()
await Promise.all(
sessions
.filter((session) => session.time.archived === undefined)
.map((session) =>
serverSDK()
.client.session.update({
sessionID: session.id,
directory: session.directory,
time: { archived: archivedAt },
})
.catch(() => undefined),
),
)
setBusy(directory, false)
dismiss()
@@ -1537,9 +1524,9 @@ export default function LegacyLayout(props: ParentProps) {
onMount(() => {
serverSDK()
.api.vcs.status({ location: { directory: props.directory } })
.then((result) => {
const files = result.data
.client.vcs.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
const dirty = files.length > 0
setData({ status: "ready", dirty })
})
@@ -1595,19 +1582,19 @@ export default function LegacyLayout(props: ParentProps) {
})
const refresh = async () => {
const sessions = await listAllSessions(serverSDK().api.session, {
directory: props.directory,
order: "desc",
}).catch(() => [])
const sessions = await serverSDK()
.client.session.list({ directory: props.directory })
.then((x) => x.data ?? [])
.catch(() => [])
const active = sessions.filter((session) => session.time.archived === undefined)
setState({ sessions: active })
}
onMount(() => {
serverSDK()
.api.vcs.status({ location: { directory: props.directory } })
.then((result) => {
const files = result.data
.client.vcs.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
const dirty = files.length > 0
setState({ status: "ready", dirty })
void refresh()
+3
View File
@@ -2329,6 +2329,9 @@ export default function Page() {
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
reviewCount={reviewCount}
reviewPanel={reviewPanelV2}
diffVersion={vcsQuery.dataUpdatedAt}
loadDiff={loadReviewDiff}
expandUnchanged={reviewV2State.expandMode() === "expand"}
reviewSidebarToggle={(disabled) => (
<SessionReviewV2SidebarToggle
opened={reviewV2State.sidebarOpened()}
@@ -25,39 +25,34 @@ export function createPromptInputController(input: {
}) {
const layout = useLayout()
const local = useLocal()
const sdk = useSDK()
const providers = useProviders()
const sync = useSync()
const providers = useProviders(() => sdk().directory)
const sdk = useSDK()
const view = layout.view(input.sessionKey)
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
return createMemo<PromptInputControls>(() => {
return {
agents: {
available: sync().data.agent,
options: local.agent.list().map((agent) => agent.name),
current: local.agent.current()?.name ?? "",
loading: agentsQuery.isLoading,
visible: local.agent.visible(),
select: local.agent.set,
},
model: {
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading:
(local.agent.visible() && agentsQuery.isLoading) ||
providersQuery.isLoading ||
globalProvidersQuery.isLoading,
},
session: {
id: input.sessionID(),
tabs: layout.tabs(input.sessionKey),
reviewPanel: view.reviewPanel,
},
}
})
return createMemo<PromptInputControls>(() => ({
agents: {
available: sync().data.agent,
options: local.agent.list().map((agent) => agent.name),
current: local.agent.current()?.name ?? "",
loading: agentsQuery.isLoading,
visible: local.agent.visible(),
select: local.agent.set,
},
model: {
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
},
session: {
id: input.sessionID(),
tabs: layout.tabs(input.sessionKey),
reviewPanel: view.reviewPanel,
},
}))
}
export function createPromptProjectControls() {
@@ -82,7 +82,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
setStore("responding", perm.id)
sdk()
.api.permission.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response })
.client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.catch((err: unknown) => {
const description = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description })
@@ -223,8 +223,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
const replyMutation = useMutation(() => ({
mutationFn: (answers: QuestionAnswer[]) =>
sdk().api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers }),
mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
onMutate: () => {
props.onSubmit()
},
@@ -236,7 +235,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}))
const rejectMutation = useMutation(() => ({
mutationFn: () => sdk().api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }),
mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
+50 -11
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -8,6 +8,7 @@ import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/sessi
import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations"
import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2"
import { sampledChecksum } from "@opencode-ai/core/util/encode"
import { normalize, text } from "@opencode-ai/session-ui/session-diff"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
@@ -18,14 +19,20 @@ import { showToast } from "@/utils/toast"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
import { useSettings } from "@/context/settings"
import { getSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { reviewDiffNeedsLoad, type RenderDiff } from "@/pages/session/v2/review-diff-kinds"
type SessionFileViewProps = {
tab: string
diff?: RenderDiff
diffVersion?: number
loadDiff?: (path: string, version?: number) => Promise<RenderDiff | undefined>
expandUnchanged?: boolean
}
const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
@@ -215,10 +222,25 @@ export function FileTabContent(props: { tab: string }) {
export function SessionFileView(props: SessionFileViewProps) {
const settings = useSettings()
const detailSource = createMemo(() => {
if (!props.diff || !props.loadDiff || !reviewDiffNeedsLoad(props.diff)) return
return { diff: props.diff, load: props.loadDiff, version: props.diffVersion }
})
const [loadedDiff] = createResource(detailSource, async ({ diff, load, version }) => {
const value = await load(diff.file, version)
if (value?.file !== diff.file) return
return { source: diff, version, value }
})
const diff = createMemo(() => {
const source = props.diff
if (!source) return
const loaded = loadedDiff()
return normalize(loaded?.source === source && loaded.version === props.diffVersion ? loaded.value : source)
})
return (
<Show when={settings.general.newLayoutDesigns()} fallback={<SessionFileViewV1 tab={props.tab} />}>
<SessionFileViewV2 tab={props.tab} />
<SessionFileViewV2 tab={props.tab} diff={diff()} expandUnchanged={props.expandUnchanged} />
</Show>
)
}
@@ -508,11 +530,12 @@ function SessionFileViewV1(props: { tab: string }) {
return content()
}
function SessionFileViewV2(props: { tab: string }) {
function SessionFileViewV2(props: { tab: string; diff?: ReturnType<typeof normalize>; expandUnchanged?: boolean }) {
const file = useFile()
const comments = useComments()
const language = useLanguage()
const prompt = usePrompt()
const layout = useLayout()
const fileComponent = useFileComponent()
const { sessionKey, tabs, view } = useSessionLayout()
const activeFileTab = createSessionTabs({
@@ -556,7 +579,12 @@ function SessionFileViewV2(props: { tab: string }) {
}
const buildPreview = (filePath: string, lines: SelectedLineRange) => {
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
const source =
filePath === path()
? props.diff
? text(props.diff, selectionSide(lines))
: contents()
: file.get(filePath)?.content?.content
if (!source) return undefined
return selectionPreview(source, selectionFromLines(lines))
}
@@ -733,12 +761,21 @@ function SessionFileViewV2(props: { tab: string }) {
<div class="relative overflow-hidden pb-40">
<Dynamic
component={fileComponent}
mode="text"
file={{
name: path() ?? "",
contents: source,
cacheKey: cacheKey(),
}}
{...(props.diff
? {
mode: "diff" as const,
fileDiff: props.diff.fileDiff,
diffStyle: layout.review.diffStyle(),
expandUnchanged: props.expandUnchanged,
}
: {
mode: "text" as const,
file: {
name: path() ?? "",
contents: source,
cacheKey: cacheKey(),
},
})}
enableLineSelection
enableGutterUtility
selectedLines={activeSelection()}
@@ -764,10 +801,11 @@ function SessionFileViewV2(props: { tab: string }) {
commentsUi.onLineNumberSelectionEnd(range)
}}
search={search}
class="select-text"
class="select-text [--opencode-diffs-bg:var(--v2-background-bg-base)]"
media={{
mode: "auto",
path: path(),
deleted: props.diff?.status === "deleted",
current: state()?.content,
onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
@@ -786,6 +824,7 @@ function SessionFileViewV2(props: { tab: string }) {
<div class="mt-3 relative h-full min-h-0">
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
<Switch>
<Match when={props.diff}>{renderFile(contents())}</Match>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
@@ -1,7 +1,6 @@
import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { SessionReview } from "@opencode-ai/session-ui/session-review"
import type {
SessionReviewCommentActions,
@@ -15,7 +14,7 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split"
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
export interface SessionReviewTabProps {
title?: JSX.Element
@@ -24,7 +24,6 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -57,22 +56,24 @@ import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
function renderDiff(value: ReviewDiff): value is RenderDiff {
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
return typeof value.file === "string"
}
export function SessionSidePanel(props: {
canReview: () => boolean
diffs: () => ReviewDiff[]
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
diffsReady: () => boolean
empty: () => string
hasReview: () => boolean
reviewHasFocusableContent: () => boolean
reviewCount: () => number
reviewPanel: () => JSX.Element
diffVersion?: number
loadDiff?: (path: string, version?: number) => Promise<RenderDiff | undefined>
expandUnchanged?: boolean
reviewSidebarToggle?: (disabled: boolean) => JSX.Element
fileBrowserState?: SessionFileBrowserState
activeDiff?: string
@@ -90,6 +91,11 @@ export function SessionSidePanel(props: {
const sdk = useSDK()
const { sessionKey, tabs, view, params } = useSessionLayout()
const projectDirectory = createMemo(() => sdk().directory)
const diffForTab = (tab: string) => {
const path = file.pathFromTab(tab)
if (!path) return
return props.diffs().find((diff): diff is RenderDiff => renderDiff(diff) && diff.file === path)
}
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = settings.visibility.fileTree
@@ -741,6 +747,10 @@ export function SessionSidePanel(props: {
active={file.pathFromTab(browserTab() ?? activeFileTab() ?? "")}
kinds={kinds()}
state={props.fileBrowserState!}
diff={diffForTab(browserTab() ?? activeFileTab() ?? "")}
diffVersion={props.diffVersion}
loadDiff={props.loadDiff}
expandUnchanged={props.expandUnchanged}
onSelect={(path) => previewTab(file.tab(path))}
onSelectPermanent={(path) => openTab(file.tab(path))}
filterRef={(element) => (fileFilter = element)}
@@ -62,7 +62,7 @@ import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
@@ -283,14 +283,6 @@ export function MessageTimeline(props: {
return sync().data.session_status[id] ?? idle
})
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
const projectedMessages = createMemo(() => {
const id = sessionID()
if (!id) return []
const visible = new Set(props.userMessages.map((message) => message.id))
const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id
const messages = sync().data.session_message[id] ?? []
return boundary ? messages.filter((message) => message.id < boundary) : messages
})
const info = createMemo(() => {
const id = sessionID()
if (!id) return
@@ -299,7 +291,8 @@ export function MessageTimeline(props: {
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const protocol = useServerProtocol()
const shareEnabled = createMemo(() => protocol() === "v1" && sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
@@ -333,7 +326,6 @@ export function MessageTimeline(props: {
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
sessionMessages: projectedMessages,
parts: getMsgParts,
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
@@ -674,7 +666,7 @@ export function MessageTimeline(props: {
const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) =>
sdk().api.session.rename({ sessionID: input.id, title: input.title }),
sdk().client.session.update({ sessionID: input.id, title: input.title }),
onSuccess: (_, input) => {
sync().set(
produce((draft) => {
@@ -812,14 +804,13 @@ export function MessageTimeline(props: {
const archiveSession = async (sessionID: string) => {
const session = sync().session.get(sessionID)
if (!session) return
if ((await sdk().protocol) !== "v1") return
const sessions = sync().data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk()
.client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } })
.client.session.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync().set(
produce((draft) => {
@@ -848,8 +839,8 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk()
.api.session.remove({ sessionID })
.then(() => true)
.client.session.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
@@ -1,15 +1,16 @@
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { Binary } from "@opencode-ai/core/util/binary"
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, type Accessor } from "solid-js"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { reuseTimelineRows } from "./row-reconciliation"
import { Timeline, TimelineRow } from "./rows"
export { reuseTimelineRows } from "./row-reconciliation"
const emptyAssistantMessages: AssistantMessage[] = []
export function createTimelineProjection(input: {
messages: Accessor<Message[]>
userMessages: Accessor<UserMessage[]>
sessionMessages: Accessor<SessionMessageInfo[]>
parts: (messageID: string) => Part[]
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
@@ -29,20 +30,47 @@ export function createTimelineProjection(input: {
})
return result
})
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(
input.sessionMessages(),
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
input.parts,
input.showReasoningSummaries(),
input.status().type,
input.inlineComments(),
input.userMessages(),
const activeMessageID = createMemo(() => {
const parentID = input
.messages()
.findLast(
(message): message is AssistantMessage =>
message.role === "assistant" && typeof message.time.completed !== "number",
)?.parentID
if (parentID) {
const messages = input.messages()
const result = Binary.search(messages, parentID, (message) => message.id)
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
if (message?.role === "user") return message.id
}
if (input.status().type === "idle") return
return input.messages().findLast((message) => message.role === "user")?.id
})
const messageRowMemos = createMemo(
mapArray(input.userMessages, (userMessage, indexAccessor) =>
createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(
previous,
Timeline.constructMessageRows(
userMessage,
input.parts,
assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages,
indexAccessor(),
input.showReasoningSummaries(),
input.status().type,
activeMessageID() === userMessage.id,
input.inlineComments(),
),
),
),
),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(previous, projection().rows),
reuseTimelineRows(
previous,
messageRowMemos().flatMap((memo) => memo()),
),
)
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
const messageRowIndex = createMemo(() => {
@@ -1,170 +0,0 @@
import { describe, expect, mock, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { normalizeSessionMessages } from "@/utils/session-message"
mock.module("@opencode-ai/session-ui/message-part", () => ({
renderable: () => true,
groupParts: (refs: Array<{ messageID: string; part: { id: string } }>) =>
refs.map((ref) => ({
type: "part" as const,
key: ref.part.id,
ref: { messageID: ref.messageID, partID: ref.part.id },
})),
}))
const { Timeline, TimelineRow } = await import("./rows")
describe("current session timeline rows", () => {
test("derives turns and tagged rows from chronological current messages", () => {
const source = [
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
{
id: "msg_2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "answer" }],
time: { created: 2, completed: 3 },
},
{ id: "msg_3", type: "user", text: "second", time: { created: 4 } },
{
id: "msg_4",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "reasoning", text: "working" }],
time: { created: 5 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"busy",
true,
normalized.messages.filter((message) => message.role === "user"),
)
expect(result.activeMessageID).toBe("msg_3")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_1",
"assistant-part:msg_1:msg_2:text:0",
"turn-gap:msg_3",
"user-message:msg_3",
"assistant-part:msg_3:msg_4:reasoning:0",
])
})
test("renders a current shell message as a standalone turn", () => {
const source = [
{
id: "msg_shell",
type: "shell",
shellID: "shell_1",
command: "pwd",
status: "exited",
exit: 0,
output: { output: "/repo", cursor: 5, size: 5, truncated: false },
time: { created: 1, completed: 2 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"idle",
true,
normalized.messages.filter((message) => message.role === "user"),
)
expect(result.activeMessageID).toBe("msg_shell")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_shell",
"assistant-part:msg_shell:msg_shell:tool",
])
})
test("keeps a projected parent missing from the source page before newer turns", () => {
const source = [
{ id: "msg_user_1", type: "user", text: "first question", time: { created: 1 } },
{
id: "msg_assistant_1",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "first answer" }],
time: { created: 2, completed: 3 },
},
{ id: "msg_user_2", type: "user", text: "second question", time: { created: 4 } },
{
id: "msg_assistant_2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "second answer" }],
time: { created: 5, completed: 6 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source.slice(1),
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"idle",
true,
normalized.messages.filter((message) => message.role === "user"),
)
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_user_1",
"assistant-part:msg_user_1:msg_assistant_1:text:0",
"turn-gap:msg_user_2",
"user-message:msg_user_2",
"assistant-part:msg_user_2:msg_assistant_2:text:0",
])
})
test("renders an optimistic user turn and thinking before the protocol message arrives", () => {
const source = [
{ id: "msg_1", type: "user", text: "existing", time: { created: 1 } },
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const optimistic = {
id: "msg_2",
sessionID: "ses_1",
role: "user" as const,
time: { created: 2 },
agent: "build",
model: { modelID: "model", providerID: "provider" },
}
const result = Timeline.constructSessionMessageRows(
source,
(messageID) =>
messageID === optimistic.id ? optimistic : normalized.messages.find((message) => message.id === messageID),
() => [],
true,
"busy",
true,
[...normalized.messages.filter((message) => message.role === "user"), optimistic],
)
expect(result.activeMessageID).toBe(optimistic.id)
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_1",
"turn-gap:msg_2",
"user-message:msg_2",
"thinking:msg_2",
])
})
})
@@ -1,5 +1,4 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
@@ -32,71 +31,6 @@ export type TimelineRowMap = {
}
export namespace Timeline {
export function constructSessionMessageRows(
messages: SessionMessageInfo[],
getMessage: (messageID: string) => UserMessage | AssistantMessage | undefined,
getMessageParts: (messageID: string) => Part[],
showReasoning: boolean,
status: SessionStatus["type"],
inlineComments: boolean,
projectedUserMessages: UserMessage[],
) {
const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = []
const turnByUserID = new Map<string, (typeof turns)[number]>()
messages.forEach((message) => {
const projected = getMessage(message.id)
if (message.type === "shell" && projected?.role === "user") {
const assistant = getMessage(`${message.id}:assistant`)
const turn = { user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] }
turns.push(turn)
turnByUserID.set(projected.id, turn)
return
}
if (projected?.role === "user") {
if (turnByUserID.has(projected.id)) return
const turn = { user: projected, assistants: [] }
turns.push(turn)
turnByUserID.set(projected.id, turn)
return
}
if (projected?.role !== "assistant") return
const existing = turnByUserID.get(projected.parentID)
if (existing) {
existing.assistants.push(projected)
return
}
const user = getMessage(projected.parentID)
if (user?.role !== "user") return
const turn = { user, assistants: [projected] }
turns.push(turn)
turnByUserID.set(user.id, turn)
})
const latestUserMessageID = turns.at(-1)?.user.id
projectedUserMessages.forEach((user) => {
if (turnByUserID.has(user.id)) return
if (latestUserMessageID && user.id < latestUserMessageID) return
const turn = { user, assistants: [] }
turns.push(turn)
turnByUserID.set(user.id, turn)
})
const activeMessageID = turns.at(-1)?.user.id
return {
activeMessageID,
rows: turns.flatMap((turn, index) =>
constructMessageRows(
turn.user,
getMessageParts,
turn.assistants,
index,
showReasoning,
status,
turn.user.id === activeMessageID,
inlineComments,
),
),
}
}
export function constructMessageRows(
userMessage: UserMessage,
getMessageParts: (messageID: string) => Part[],
@@ -5,6 +5,7 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b
import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
@@ -18,7 +19,7 @@ import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useLocal } from "@/context/local"
import { useServerProtocol } from "@/context/server-sdk"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
@@ -40,14 +41,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const dialog = useDialog()
const file = useFile()
const language = useLanguage()
const local = useLocal()
const permission = usePermission()
const prompt = usePrompt()
const sdk = useSDK()
const settings = useSettings()
const protocol = useServerProtocol()
const sync = useSync()
const terminal = useTerminal()
const layout = useLayout()
const local = useLocal()
const navigate = useNavigate()
const { params, sessionKey, tabs, view } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
@@ -184,6 +186,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const share = async () => {
if (protocol() !== "v1") return
const sessionID = params.id
if (!sessionID) return
@@ -210,6 +213,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const unshare = async () => {
if (protocol() !== "v1") return
const sessionID = params.id
if (!sessionID) return
@@ -306,7 +310,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionID = params.id
if (!sessionID) return
const owner = sessionOwnership.capture()
const session = sdk().api.session
const client = sdk().client
const directory = sdk().directory
const promptSession = prompt.capture()
const revert = info()?.revert?.messageID
@@ -316,13 +320,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const parts = sync().data.part[message.id]
if (sync().data.session_working(sessionID)) {
await session.interrupt({ sessionID }).catch(() => {})
await client.session.abort({ sessionID }).catch(() => {})
}
await runCommand({
owner,
prompt: promptSession,
request: () => session.revert.stage({ sessionID, messageID: message.id }),
request: () => client.session.revert({ sessionID, messageID: message.id }),
updatePrompt: (promptSession) => {
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
},
@@ -334,7 +338,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionID = params.id
if (!sessionID) return
const owner = sessionOwnership.capture()
const session = sdk().api.session
const client = sdk().client
const messages = userMessages()
const promptSession = prompt.capture()
@@ -346,7 +350,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
await runCommand({
owner,
prompt: promptSession,
request: () => session.revert.clear({ sessionID }),
request: () => client.session.unrevert({ sessionID }),
updatePrompt: (promptSession) => promptSession.reset(),
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
})
@@ -356,7 +360,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
await runCommand({
owner,
prompt: promptSession,
request: () => session.revert.stage({ sessionID, messageID: next.id }),
request: () => client.session.revert({ sessionID, messageID: next.id }),
updatePrompt: () => undefined,
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
})
@@ -375,9 +379,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
return
}
await sdk().api.session.compact({
await sdk().client.session.summarize({
sessionID,
model: { providerID: model.provider.id, modelID: model.id },
modelID: model.id,
providerID: model.provider.id,
})
}
@@ -389,6 +394,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const shareCmds = () => {
if (protocol() !== "v1") return []
if (sync().data.config.share === "disabled") return []
return [
sessionCommand({
@@ -1,15 +1,14 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Kind } from "@/components/file-tree-v2"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
export type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
export function normalizePath(p: string) {
return normalizeFileTreeV2Path(p)
}
export function filterRenderableDiff(value: FileDiffInfo | SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
return typeof value.file === "string"
}
@@ -1,6 +1,5 @@
import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
@@ -31,7 +30,7 @@ import {
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
export type ReviewPanelV2Props = {
title?: JSX.Element
@@ -11,6 +11,7 @@ import { useSDK } from "@/context/sdk"
import { displayName } from "@/pages/layout/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileView } from "@/pages/session/file-tabs"
import type { RenderDiff } from "@/pages/session/v2/review-diff-kinds"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
import { pathKey } from "@/utils/path-key"
@@ -30,6 +31,10 @@ export function SessionFileBrowserTab(props: {
active?: string
kinds: ReadonlyMap<string, Kind>
state: SessionFileBrowserState
diff?: RenderDiff
diffVersion?: number
loadDiff?: (path: string, version?: number) => Promise<RenderDiff | undefined>
expandUnchanged?: boolean
onSelect: (path: string) => void
onSelectPermanent: (path: string) => void
filterRef?: (element: HTMLInputElement) => void
@@ -172,7 +177,15 @@ export function SessionFileBrowserTab(props: {
>
<div class="min-h-0 flex-1">
<Show when={props.tab} keyed>
{(tab) => <SessionFileView tab={tab} />}
{(tab) => (
<SessionFileView
tab={tab}
diff={props.diff}
diffVersion={props.diffVersion}
loadDiff={props.loadDiff}
expandUnchanged={props.expandUnchanged}
/>
)}
</Show>
</div>
</Show>
+1 -2
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { diffs, message } from "./diffs"
@@ -10,7 +9,7 @@ const item = {
additions: 1,
deletions: 1,
status: "modified",
} satisfies FileDiffInfo & SnapshotFileDiff
} satisfies SnapshotFileDiff
describe("diffs", () => {
test("keeps valid arrays", () => {
+1 -2
View File
@@ -1,8 +1,7 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Message } from "@opencode-ai/sdk/v2/client"
type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
type Diff = SnapshotFileDiff | VcsFileDiff
function diff(value: unknown): value is Diff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
+9 -119
View File
@@ -53,7 +53,6 @@ function setup(
}
describe("createCompatibleApi", () => {
/*
test("routes V1 archive through the legacy session update", async () => {
const { api, requests } = setup("v1")
await api.session.archive({ sessionID: "ses_1", directory: "/repo" })
@@ -64,69 +63,32 @@ describe("createCompatibleApi", () => {
expect(requests[0]!.method).toBe("PATCH")
expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } })
})
*/
test("converts current prompts to the V1 prompt contract", async () => {
const { api, requests } = setup("v1")
await api.session.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "hello @src/index.ts",
text: "hello",
agent: "build",
model: { providerID: "provider", modelID: "model" },
files: [
{ uri: "file:///repo/src/index.ts", name: "index.ts", mention: { text: "@src/index.ts", start: 6, end: 19 } },
{ uri: "data:text/plain;base64,aGVsbG8=", name: "notes.txt" },
],
})
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async")
const body = await requests[0]!.json()
expect(body).toMatchObject({
expect(await requests[0]!.json()).toMatchObject({
messageID: "msg_1",
agent: "build",
model: { providerID: "provider", modelID: "model" },
parts: [
{ type: "text", text: "hello @src/index.ts" },
{
type: "file",
mime: "text/plain",
url: "file:///repo/src/index.ts",
filename: "index.ts",
source: {
type: "file",
text: { value: "@src/index.ts", start: 6, end: 19 },
path: "file:///repo/src/index.ts",
},
},
{
type: "file",
mime: "text/plain",
url: "data:text/plain;base64,aGVsbG8=",
filename: "notes.txt",
},
],
parts: [{ type: "text", text: "hello" }],
})
expect(body.parts[2]).not.toHaveProperty("source")
})
test("preserves original parts for V1 optimistic reconciliation", async () => {
const { api, requests } = setup("v1")
await api.session.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "look",
files: [{ uri: "data:image/png;base64,AAAA", name: "image.png" }],
legacyParts: [
{ id: "prt_text", type: "text", text: "look" },
{ id: "prt_image", type: "file", mime: "image/png", url: "data:image/png;base64,AAAA", filename: "image.png" },
],
})
test("keeps V2 session actions on the current API", async () => {
const { api, requests } = setup("v2")
await api.session.archive({ sessionID: "ses_1" })
expect((await requests[0]!.json()).parts).toEqual([
{ id: "prt_text", type: "text", text: "look" },
{ id: "prt_image", type: "file", mime: "image/png", url: "data:image/png;base64,AAAA", filename: "image.png" },
])
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
expect(requests[0]!.method).toBe("POST")
})
test("resolves protocol detection once across implementation methods", async () => {
@@ -141,22 +103,12 @@ describe("createCompatibleApi", () => {
})
const { api } = setup(protocol)
await api.session.list()
await api.session.archive({ sessionID: "ses_1" })
await api.session.list()
expect(detections).toBe(1)
})
/*
test("keeps V2 session actions on the current API", async () => {
const { api, requests } = setup("v2")
await api.session.archive({ sessionID: "ses_1" })
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
expect(requests[0]!.method).toBe("POST")
})
*/
test("uses the global V1 session search endpoint", async () => {
const { api, requests } = setup("v1")
await api.session.list({ parentID: null, search: "session", limit: 50 })
@@ -164,7 +116,6 @@ describe("createCompatibleApi", () => {
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
})
/*
test("projects the V1 default branch", async () => {
const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } })
@@ -172,65 +123,4 @@ describe("createCompatibleApi", () => {
data: { branch: "feature", defaultBranch: "dev" },
})
})
*/
test("translates current file searches to the V1 dirs parameter", async () => {
const { api, requests } = setup("v1")
await api.file.find({ location: { directory: "/repo" }, query: "src", type: "file", limit: 20 })
const url = new URL(requests[0]!.url)
expect(url.pathname).toBe("/find/file")
expect(url.searchParams.get("dirs")).toBe("false")
expect(url.searchParams.get("limit")).toBe("20")
})
test("routes V1 permission replies through the requested directory", async () => {
const { api, requests } = setup("v1")
await api.permission.reply({
sessionID: "ses_1",
requestID: "permission_1",
reply: "once",
location: { directory: "/other" },
})
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/permissions/permission_1")
expect(new URL(requests[0]!.url).searchParams.get("directory")).toBe("/other")
})
test("disposes the V1 instance after connecting a provider", async () => {
const { api, requests } = setup("v1")
await api.integration.connect.key({
integrationID: "openrouter",
key: "secret",
location: { directory: "/repo" },
})
expect(requests.map((request) => new URL(request.url).pathname)).toEqual([
"/auth/openrouter",
"/instance/dispose",
"/instance/dispose",
])
expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull()
})
test("disposes the V1 instance after completing provider OAuth", async () => {
const { api, requests } = setup("v1")
await api.integration.oauth.complete({
integrationID: "openrouter",
attemptID: "openrouter:0",
code: "code",
location: { directory: "/repo" },
})
expect(requests.map((request) => new URL(request.url).pathname)).toEqual([
"/provider/openrouter/oauth/callback",
"/instance/dispose",
"/instance/dispose",
])
expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull()
})
})
+40 -63
View File
@@ -1,6 +1,6 @@
import type { ServerApi } from "./server"
import type { ServerProtocol } from "./server-protocol"
import type { AgentPartInput, FilePartInput, OpencodeClient, Session, TextPartInput } from "@opencode-ai/sdk/v2/client"
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
import type {
Project,
ProjectCurrent,
@@ -27,23 +27,14 @@ type CompatibleSessionApi = Omit<
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
// archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
}
type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
reply: (
input: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } },
) => ReturnType<ServerApi["permission"]["reply"]>
}
export type CompatibleApi = Omit<ServerApi, "session" | "permission"> & {
readonly session: CompatibleSessionApi
readonly permission: CompatiblePermissionApi
}
export type CompatibleApi = Omit<ServerApi, "session"> & { readonly session: CompatibleSessionApi }
type LegacyPrompt = {
agent?: string
model?: { providerID: string; modelID: string }
variant?: string
legacyParts?: (TextPartInput | FilePartInput | AgentPartInput)[]
}
type LegacyLocation = { directory?: string }
type CompatibleInput = {
@@ -183,9 +174,9 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
},
// async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
// await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
// },
async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
},
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
await legacy(value).session.delete(value)
},
@@ -204,20 +195,13 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
agent: value.agent,
model: value.model,
variant: value.variant,
parts: value.legacyParts ?? [
parts: [
{ type: "text", text: value.text },
...(value.files ?? []).map((file) => ({
type: "file" as const,
mime: file.mention ? "text/plain" : mime(file.uri),
mime: mime(file.uri),
url: file.uri,
filename: file.name,
source: file.mention
? {
type: "file" as const,
text: { value: file.mention.text, start: file.mention.start, end: file.mention.end },
path: file.uri,
}
: undefined,
})),
...(value.agents ?? []).map((agent) => ({
type: "agent" as const,
@@ -308,34 +292,34 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
if (!result.data) throw new Error("Project not found")
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
},
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
// const result = await legacy({ directory: project?.worktree }).project.update({
// ...value,
// directory: project?.worktree,
// })
// if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
// return result.data as Project
// },
async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
const result = await legacy({ directory: project?.worktree }).project.update({
...value,
directory: project?.worktree,
})
if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
return result.data as Project
},
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
const result = await legacy(value.location).worktree.list()
return (result.data ?? []).map((item) => ({ directory: item }))
},
},
// path: {
// ...input.current.path,
// async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
// const result = await legacy(value?.location).path.get()
// if (!result.data) throw new Error("Path unavailable")
// return result.data
// },
// },
path: {
...input.current.path,
async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
const result = await legacy(value?.location).path.get()
if (!result.data) throw new Error("Path unavailable")
return result.data
},
},
vcs: {
...input.current.vcs,
// async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
// const result = await legacy(value?.location).vcs.get()
// return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
// },
async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
const result = await legacy(value?.location).vcs.get()
return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
},
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
const result = await legacy(value?.location).vcs.status()
return located(result.data ?? [], value?.location)
@@ -366,7 +350,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
const result = await legacy(value.location).find.files({
query: value.query,
dirs: value.type === undefined ? undefined : value.type === "directory" ? "true" : "false",
type: value.type,
limit: value.limit,
})
return located(
@@ -401,8 +385,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
providerID: value.integrationID,
auth: { type: "api", key: value.key },
})
await legacy(value.location).instance.dispose()
await input.legacy().instance.dispose()
},
},
oauth: {
@@ -431,8 +413,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
{ providerID: value.integrationID, method, code: value.code },
{ throwOnError: true },
)
await legacy(value.location).instance.dispose()
await input.legacy().instance.dispose()
},
status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
const method = Number(value.attemptID.split(":").at(-1))
@@ -440,8 +420,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
{ providerID: value.integrationID, method },
{ throwOnError: true },
)
await legacy(value.location).instance.dispose()
await input.legacy().instance.dispose()
return located(
{ status: "complete" as const, time: { created: Date.now(), expires: Date.now() } },
value.location,
@@ -451,9 +429,9 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
},
pty: {
...input.current.pty,
// async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
// return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
// },
async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
},
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
},
@@ -485,20 +463,19 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
},
// async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
// const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
// if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
// return located(result.data, value.location)
// },
async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
return located(result.data, value.location)
},
},
permission: {
...input.current.permission,
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } }) {
await legacy(value.location).permission.respond({
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) {
await legacy().permission.respond({
sessionID: value.sessionID,
permissionID: value.requestID,
response: value.reply,
directory: directory(value.location),
})
},
},
+3 -19
View File
@@ -15,7 +15,7 @@ describe("normalizeSessionMessages", () => {
{
id: "msg_3",
type: "user",
text: "inspect @src/client.ts",
text: "inspect this",
files: [
{
data: "aGVsbG8=",
@@ -23,13 +23,6 @@ describe("normalizeSessionMessages", () => {
name: "note.txt",
source: { type: "inline" },
},
{
data: "ZXhwb3J0IHt9",
mime: "text/plain",
name: "client.ts",
source: { type: "inline" },
mention: { text: "@src/client.ts", start: 8, end: 22 },
},
],
agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
time: { created: 3 },
@@ -49,7 +42,7 @@ describe("normalizeSessionMessages", () => {
state: {
status: "completed",
input: { filePath: "note.txt" },
metadata: { title: "note.txt" },
structured: { title: "note.txt" },
content: [{ type: "text", text: "hello" }],
},
time: { created: 5, ran: 6, completed: 7 },
@@ -83,18 +76,9 @@ describe("normalizeSessionMessages", () => {
expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([
"msg_3:text:0",
"msg_3:file:0",
"msg_3:file:1",
"msg_3:agent:0",
"msg_5:compaction",
])
expect(result.parts.get("msg_3")?.[2]).toMatchObject({
type: "file",
source: {
type: "file",
path: "src/client.ts",
text: { value: "@src/client.ts", start: 8, end: 22 },
},
})
expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"])
expect(result.parts.get("msg_4")?.[2]).toMatchObject({
type: "tool",
@@ -170,7 +154,7 @@ describe("normalizeSessionMessages", () => {
status: "completed",
input: { path: "/repo/README.md", oldString: "old", newString: "new" },
content: [{ type: "text", text: "Edited file successfully" }],
metadata: {
structured: {
files: [
{
file: "README.md",
+3 -13
View File
@@ -206,13 +206,6 @@ function userParts(sessionID: string, message: SessionMessageUser): Part[] {
mime: file.mime,
filename: file.name,
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
source: file.mention
? {
type: "file",
text: { value: file.mention.text, start: file.mention.start, end: file.mention.end },
path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : (file.name ?? file.mention.text),
}
: undefined,
}),
),
...(message.agents ?? []).map(
@@ -304,8 +297,7 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi
return {
status: "running" as const,
input: normalizeToolInput(tool.name, tool.state.input),
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
metadata: normalizeToolMetadata(tool.name, tool.state.structured),
time: { start },
}
}
@@ -314,8 +306,7 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi
status: "error" as const,
input: normalizeToolInput(tool.name, tool.state.input),
error: tool.state.error.message,
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
metadata: normalizeToolMetadata(tool.name, tool.state.structured),
time: { start, end: tool.time.completed ?? start },
}
}
@@ -339,8 +330,7 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi
input: normalizeToolInput(tool.name, tool.state.input),
output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"),
title: tool.name,
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
metadata: normalizeToolMetadata(tool.name, tool.state.structured),
time: { start, end: tool.time.completed ?? start },
attachments: attachments.length ? attachments : undefined,
}
@@ -2,28 +2,8 @@ import { describe, expect, test } from "bun:test"
import { terminalWebSocketURL } from "./terminal-websocket-url"
describe("terminalWebSocketURL", () => {
test("uses the current ticketed PTY route", () => {
test("uses query auth without embedding credentials in websocket URL", () => {
const url = terminalWebSocketURL({
url: "http://127.0.0.1:49365",
id: "pty_test",
directory: "/tmp/project",
cursor: 0,
ticket: "connect-ticket",
})
expect(url.protocol).toBe("ws:")
expect(url.username).toBe("")
expect(url.password).toBe("")
expect(url.pathname).toBe("/api/pty/pty_test/connect")
expect(url.searchParams.get("location[directory]")).toBe("/tmp/project")
expect(url.searchParams.get("cursor")).toBe("0")
expect(url.searchParams.get("ticket")).toBe("connect-ticket")
expect(url.searchParams.has("auth_token")).toBe(false)
})
test("uses query auth without embedding credentials in websocket URL for v1", () => {
const url = terminalWebSocketURL({
protocol: "v1",
url: "http://127.0.0.1:49365",
id: "pty_test",
directory: "/tmp/project",
@@ -36,14 +16,11 @@ describe("terminalWebSocketURL", () => {
expect(url.protocol).toBe("ws:")
expect(url.username).toBe("")
expect(url.password).toBe("")
expect(url.pathname).toBe("/pty/pty_test/connect")
expect(url.searchParams.get("directory")).toBe("/tmp/project")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
test("omits query auth for same-origin saved credentials for v1", () => {
test("omits query auth for same-origin saved credentials", () => {
const url = terminalWebSocketURL({
protocol: "v1",
url: "https://app.example.test",
id: "pty_test",
directory: "/tmp/project",
@@ -54,14 +31,11 @@ describe("terminalWebSocketURL", () => {
})
expect(url.protocol).toBe("wss:")
expect(url.pathname).toBe("/pty/pty_test/connect")
expect(url.searchParams.get("directory")).toBe("/tmp/project")
expect(url.searchParams.has("auth_token")).toBe(false)
})
test("uses query auth for same-origin credentials from auth_token for v1", () => {
test("uses query auth for same-origin credentials from auth_token", () => {
const url = terminalWebSocketURL({
protocol: "v1",
url: "https://app.example.test",
id: "pty_test",
directory: "/tmp/project",
@@ -73,8 +47,6 @@ describe("terminalWebSocketURL", () => {
})
expect(url.protocol).toBe("wss:")
expect(url.pathname).toBe("/pty/pty_test/connect")
expect(url.searchParams.get("directory")).toBe("/tmp/project")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
})
@@ -1,7 +1,6 @@
import { authTokenFromCredentials } from "@/utils/server"
export function terminalWebSocketURL(input: {
protocol?: "v1" | "v2"
url: string
id: string
directory: string
@@ -12,24 +11,18 @@ export function terminalWebSocketURL(input: {
password?: string
authToken?: boolean
}) {
const isV1 = input.protocol === "v1"
const next = new URL(`${input.url}${isV1 ? `/pty/${input.id}/connect` : `/api/pty/${input.id}/connect`}`)
if (isV1) {
next.searchParams.set("directory", input.directory)
} else {
next.searchParams.set("location[directory]", input.directory)
}
const next = new URL(`${input.url}/pty/${input.id}/connect`)
next.searchParams.set("directory", input.directory)
next.searchParams.set("cursor", String(input.cursor))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (input.ticket) {
next.searchParams.set("ticket", input.ticket)
return next
}
if (isV1 && input.password && (!input.sameOrigin || input.authToken)) {
if (input.password && (!input.sameOrigin || input.authToken))
next.searchParams.set(
"auth_token",
authTokenFromCredentials({ username: input.username, password: input.password }),
)
}
return next
}

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