mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 00:36:20 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67e232840e |
@@ -303,7 +303,7 @@
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/mistral": "3.0.34",
|
||||
"@ai-sdk/openai": "3.0.84",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
@@ -578,7 +578,7 @@
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/mistral": "3.0.34",
|
||||
"@ai-sdk/openai": "3.0.84",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
@@ -1076,12 +1076,12 @@
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@ai-sdk/mistral@3.0.34": "patches/@ai-sdk%2Fmistral@3.0.34.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -1202,7 +1202,7 @@
|
||||
|
||||
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
|
||||
|
||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
|
||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.34", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HpK28sWGdIfg1vTSScJNtzVdvNRfA4mfCmPmPR+j/MGJ0oAuEJMqxWkL96ZnGPdhZt5KdW09aKovdIe+q2zQ7A=="],
|
||||
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
|
||||
|
||||
@@ -5702,9 +5702,9 @@
|
||||
|
||||
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
"@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
"@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
|
||||
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CsKNLKsOpvPujRlIYvoz+Ybw+kGn7J4/fIZa/58+R7iWLLfwn6ifE2G6Yq8K9XvH/I/3bzaDAJ3NhRwEMsLBKQ=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
|
||||
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
|
||||
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
|
||||
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
|
||||
"x86_64-linux": "sha256-L741oedvozk0cIVnaZnujvwWrK+WXINv9KiKxYRfVwQ=",
|
||||
"aarch64-linux": "sha256-ThzQ4nCLbaLiKA7cBHI7OMAlXb+8Hchm3HojGnIAEz0=",
|
||||
"aarch64-darwin": "sha256-YdXOgFgYRu4tKw90+7F1reCihO+JC33dGk51J8NTRIk=",
|
||||
"x86_64-darwin": "sha256-Ea5X2mYHGch3JyA6wC0uH3zBEzQcFT/adqJ1+7LtRdQ="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"@ai-sdk/mistral@3.0.34": "patches/@ai-sdk%2Fmistral@3.0.34.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
# V1 API Migration Checklist
|
||||
|
||||
The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
|
||||
|
||||
## Events
|
||||
|
||||
- [x] Replace `GET /global/event` with `GET /api/event`.
|
||||
- `src/context/server-sdk.tsx`
|
||||
- [x] Reduce current granular session and message events into the existing app projections.
|
||||
- `src/context/server-session-v2-reducer.ts`
|
||||
- `src/context/server-session.ts`
|
||||
- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
- `src/context/server-session.ts`
|
||||
- `src/context/notification.tsx`
|
||||
- `src/pages/session/usage-exceeded-dialogs.tsx`
|
||||
- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
- `src/context/server-session.ts`
|
||||
- [x] Adapt current permission and question events to the existing request model.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
- `src/context/permission.tsx`
|
||||
- [x] Consume current file watcher events.
|
||||
- `src/context/file.tsx`
|
||||
- [x] Consume current VCS events.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Consume current `pty.exited` events.
|
||||
- `src/context/terminal.tsx`
|
||||
- [ ] Migrate LSP and reference events.
|
||||
- `src/context/global-sync/event-reducer.ts`
|
||||
|
||||
## Sessions
|
||||
|
||||
- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Migrate session listing from `GET /session`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- `src/context/directory-sync.ts`
|
||||
- `src/pages/layout.tsx`
|
||||
- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
|
||||
- `src/components/titlebar.tsx`
|
||||
- [x] Migrate session updates from `PATCH /session/:sessionID`.
|
||||
- `src/context/directory-sync.ts`
|
||||
- `src/context/layout.tsx`
|
||||
- `src/pages/home.tsx`
|
||||
- `src/pages/layout.tsx`
|
||||
- `src/pages/session/timeline/message-timeline.tsx`
|
||||
- `src/components/titlebar-tab-nav.tsx`
|
||||
- Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
|
||||
- [x] Migrate session deletion from `DELETE /session/:sessionID`.
|
||||
- `src/pages/session/timeline/message-timeline.tsx`
|
||||
- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
|
||||
- Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
|
||||
- [x] Migrate abort from `POST /session/:sessionID/abort`.
|
||||
- `src/components/prompt-input/submit.ts`
|
||||
- `src/pages/session/use-session-commands.tsx`
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
|
||||
- `src/pages/session/use-session-commands.tsx`
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
|
||||
- `src/pages/session/use-session-commands.tsx`
|
||||
- [x] Migrate slash commands from `POST /session/:sessionID/command`.
|
||||
- `src/components/prompt-input/submit.ts`
|
||||
- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
|
||||
- `src/components/prompt-input/submit.ts`
|
||||
- [x] Migrate session fork from `POST /session/:sessionID/fork`.
|
||||
- `src/components/dialog-fork.tsx`
|
||||
- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
|
||||
- `src/pages/session/use-session-commands.tsx`
|
||||
- `src/pages/session/timeline/message-timeline.tsx`
|
||||
- Blocked: the current API has no sharing contract or implementation.
|
||||
|
||||
## Session Compatibility Fallbacks
|
||||
|
||||
These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
|
||||
|
||||
- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
|
||||
- `src/context/server-session.ts`
|
||||
- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
|
||||
- `src/context/server-session.ts`
|
||||
- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
|
||||
- `src/context/server-session.ts`
|
||||
|
||||
## Filesystem
|
||||
|
||||
- [ ] Migrate file listing from `GET /file`.
|
||||
- `src/context/file.tsx`
|
||||
- [ ] Migrate file reads from `GET /file/content`.
|
||||
- `src/context/file.tsx`
|
||||
- `src/pages/session/review-tab.tsx`
|
||||
- `src/pages/session/v2/review-panel-v2.tsx`
|
||||
- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- `src/components/dialog-select-directory.tsx`
|
||||
- `src/components/dialog-select-directory-v2.tsx`
|
||||
|
||||
## Projects And Worktrees
|
||||
|
||||
- [x] Migrate project listing from `GET /project` to `GET /api/project`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [ ] Migrate Git initialization from `POST /project/git/init`.
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
|
||||
- `src/context/layout.tsx`
|
||||
- `src/components/edit-project.ts`
|
||||
- `src/pages/layout.tsx`
|
||||
- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
|
||||
- `src/pages/layout.tsx`
|
||||
- `src/components/prompt-input/submit.ts`
|
||||
- Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
|
||||
- [ ] Migrate instance disposal from `POST /instance/dispose`.
|
||||
- `src/pages/layout.tsx`
|
||||
|
||||
## VCS
|
||||
|
||||
- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
|
||||
- `src/pages/session.tsx`
|
||||
- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
|
||||
- `src/pages/layout.tsx`
|
||||
|
||||
## Configuration And Authentication
|
||||
|
||||
- [ ] Migrate global configuration reads from `GET /global/config`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [ ] Migrate directory configuration reads from `GET /config`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [ ] Migrate global configuration updates from `PATCH /global/config`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
|
||||
- `src/components/dialog-connect-provider.tsx`
|
||||
- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
|
||||
- `src/components/dialog-connect-provider.tsx`
|
||||
- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
|
||||
- Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
|
||||
- `src/components/dialog-connect-provider.tsx`
|
||||
- `src/components/dialog-custom-provider.tsx`
|
||||
- `src/components/settings-providers.tsx`
|
||||
- `src/components/settings-v2/providers.tsx`
|
||||
- [ ] Migrate global disposal from `POST /global/dispose`.
|
||||
- `src/components/dialog-connect-provider.tsx`
|
||||
- `src/components/settings-providers.tsx`
|
||||
- `src/components/settings-v2/providers.tsx`
|
||||
|
||||
## Permissions And Questions
|
||||
|
||||
- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- `src/context/permission.tsx`
|
||||
- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
|
||||
- `src/context/permission.tsx`
|
||||
- `src/pages/session/composer/session-composer-state.ts`
|
||||
- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
|
||||
- `src/pages/session/composer/session-question-dock.tsx`
|
||||
|
||||
## Commands, MCP, LSP, And References
|
||||
|
||||
- [x] Migrate command listing from `GET /command` to `GET /api/command`.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [ ] Migrate LSP status from `GET /lsp`.
|
||||
- `src/context/server-sync.tsx`
|
||||
- [x] Move `GET /api/reference` off the legacy generated SDK transport.
|
||||
- `src/context/global-sync/bootstrap.ts`
|
||||
|
||||
## Search
|
||||
|
||||
- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
|
||||
- `src/components/command-palette.ts`
|
||||
- `src/components/dialog-command-palette-v2.tsx`
|
||||
|
||||
## PTY And Terminal
|
||||
|
||||
- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
|
||||
- `src/context/terminal.tsx`
|
||||
- `src/components/terminal.tsx`
|
||||
- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
|
||||
- `src/components/settings-general.tsx`
|
||||
- `src/components/settings-v2/general.tsx`
|
||||
- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
|
||||
- `src/components/terminal.tsx`
|
||||
- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
|
||||
- `src/components/terminal.tsx`
|
||||
|
||||
## Legacy Types And Adapters
|
||||
|
||||
These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
|
||||
|
||||
- [ ] Replace the current-session-to-legacy-session adapter.
|
||||
- `src/utils/session.ts`
|
||||
- [ ] Replace the current-message-to-legacy-message-and-part adapter.
|
||||
- `src/utils/session-message.ts`
|
||||
- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
|
||||
- `src/context/global-sync/utils.ts`
|
||||
- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
|
||||
- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
|
||||
- `package.json`
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
- [ ] Replace V1 endpoint mocks with current API mocks.
|
||||
- `e2e/utils/mock-server.ts`
|
||||
- [x] Replace `/global/event` and `/event` interception with current event transport handling.
|
||||
- `e2e/utils/sse-transport.ts`
|
||||
- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
|
||||
- `e2e/performance/timeline-stability/fixture.ts`
|
||||
- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.
|
||||
@@ -97,7 +97,6 @@ export async function setupTimeline(
|
||||
locale?: string
|
||||
deviceScaleFactor?: number
|
||||
seedHistory?: boolean
|
||||
protocol?: "v1" | "v2"
|
||||
} = {},
|
||||
) {
|
||||
const sessions = input.sessions ?? [session()]
|
||||
@@ -115,7 +114,6 @@ export async function setupTimeline(
|
||||
retry: input.eventRetry ?? 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: input.protocol,
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
|
||||
@@ -41,7 +41,12 @@ 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 lastPartID = assistants.at(-1)!.parts.at(-1)!.id
|
||||
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}`
|
||||
|
||||
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
||||
benchmark.setTimeout(180_000)
|
||||
@@ -107,9 +112,25 @@ 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) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
|
||||
)
|
||||
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 installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
@@ -144,8 +165,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
||||
parent: requests.filter((request) => request.type === "parent").length,
|
||||
}
|
||||
if (mode === "candidate") {
|
||||
expect(requestCounts.parent).toBe(1)
|
||||
expect(historyGates).toBe(1)
|
||||
expect(requestCounts.parent).toBe(0)
|
||||
expect(historyGates).toBe(0)
|
||||
}
|
||||
return { metrics, requestCounts, historyGateCount: historyGates }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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"
|
||||
@@ -33,7 +34,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}/session/${sessionB.id}`))).toBe(true)
|
||||
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/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)
|
||||
@@ -84,16 +85,19 @@ 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") return sse(route)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/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 === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname))
|
||||
return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
@@ -116,7 +120,17 @@ 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,6 +1,7 @@
|
||||
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"
|
||||
@@ -180,10 +181,35 @@ 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") return sse(route)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/session/status") return json(route, {})
|
||||
if (url.pathname === "/session") return json(route, sessions)
|
||||
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: {} })
|
||||
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)
|
||||
@@ -193,7 +219,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
permissionRequests.push(url.toString())
|
||||
return json(route, [])
|
||||
}
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
|
||||
@@ -216,7 +242,12 @@ 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,5 +1,6 @@
|
||||
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"
|
||||
@@ -57,11 +58,14 @@ 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") return sse(route)
|
||||
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, { 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 === "/api/session/active")
|
||||
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
@@ -90,7 +94,17 @@ 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, {})
|
||||
})
|
||||
}
|
||||
@@ -104,10 +118,10 @@ function json(route: Route, body: unknown, status = 200) {
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
function sse(route: Route, current: boolean) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: `data: ${JSON.stringify({ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } })}\n\n`,
|
||||
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -143,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 === "/vcs/diff")
|
||||
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
|
||||
await page.getByRole("tab", { name: "Changes" }).click()
|
||||
expect(await (await diffResponse).json()).toHaveLength(1)
|
||||
expect((await (await diffResponse).json()).data).toHaveLength(1)
|
||||
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
await expectAppVisible(review)
|
||||
|
||||
@@ -133,7 +133,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebarToggle).toBeDisabled()
|
||||
await panel.locator("#session-side-panel-review-tab").click()
|
||||
await panel.getByRole("tab", { name: /Review/ }).click()
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await panel.getByRole("tab", { name: "Open file" }).click()
|
||||
await page.keyboard.press("Control+w")
|
||||
|
||||
@@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
|
||||
|
||||
async function selectMode(page: Page, current: string, next: string) {
|
||||
await page.getByRole("button", { name: current }).click()
|
||||
await page.getByRole("option", { name: next }).dispatchEvent("click")
|
||||
await page.getByRole("option", { name: next }).click()
|
||||
}
|
||||
|
||||
async function selectFile(page: Page, file: string) {
|
||||
@@ -92,18 +92,20 @@ async function setup(page: Page) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
|
||||
body: JSON.stringify({ location: { directory }, data: { branch: "feature", defaultBranch: "dev" } }),
|
||||
}),
|
||||
)
|
||||
await page.route("**/vcs/diff**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
new URL(route.request().url()).searchParams.get("mode") === "branch"
|
||||
? [diff("src/alpha.ts"), diff("src/beta.ts")]
|
||||
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
|
||||
),
|
||||
body: JSON.stringify({
|
||||
location: { directory },
|
||||
data:
|
||||
new URL(route.request().url()).searchParams.get("mode") === "branch"
|
||||
? [diff("src/alpha.ts"), diff("src/beta.ts")]
|
||||
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(
|
||||
|
||||
@@ -64,39 +64,80 @@ 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({
|
||||
location: { directory },
|
||||
data: { branch: "review-pane-performance", defaultBranch: "dev" },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route("**/vcs/diff**", (route) => {
|
||||
await page.route("**/api/vcs/diff**", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
|
||||
const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/")
|
||||
const detail = scope?.endsWith("/src/branch/d00027")
|
||||
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
url.searchParams.get("mode") === "branch"
|
||||
? detail
|
||||
? branchDiffs
|
||||
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
|
||||
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
|
||||
: branchDiffs
|
||||
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
|
||||
),
|
||||
body: JSON.stringify({
|
||||
location: { directory: scope ?? directory, project: { id: projectID, directory } },
|
||||
data:
|
||||
url.searchParams.get("mode") === "branch"
|
||||
? detail
|
||||
? branchDiffs
|
||||
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
|
||||
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
|
||||
: branchDiffs
|
||||
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
await page.route("**/api/pty?*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
|
||||
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", (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
await page.route("**/api/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.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
||||
await page.route("**/api/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.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
@@ -134,8 +175,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
const lazyDiff = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return (
|
||||
url.pathname === "/vcs/diff" &&
|
||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
url.pathname === "/api/vcs/diff" &&
|
||||
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
)
|
||||
})
|
||||
await lastFile.click()
|
||||
@@ -148,8 +189,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
const refreshedDiff = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return (
|
||||
url.pathname === "/vcs/diff" &&
|
||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
url.pathname === "/api/vcs/diff" &&
|
||||
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
)
|
||||
})
|
||||
events.push(statusEvent("idle"))
|
||||
|
||||
@@ -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("**/path?*", async (route) => {
|
||||
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
|
||||
await page.route("**/api/path?*", async (route) => {
|
||||
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
|
||||
await pathBlocked
|
||||
return route.fallback()
|
||||
})
|
||||
|
||||
@@ -42,7 +42,8 @@ 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 === "/question/question-request/reject") rejectRequests.push(request.url())
|
||||
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
|
||||
rejectRequests.push(request.url())
|
||||
})
|
||||
|
||||
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||
@@ -64,7 +65,9 @@ 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 === "/question/question-request/reply",
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
|
||||
)
|
||||
await question.getByRole("button", { name: "Submit" }).click()
|
||||
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
||||
@@ -97,8 +100,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(`/session/${sessionID}/permissions/permission-request`)
|
||||
expect(request.postDataJSON()).toEqual({ response: "once" })
|
||||
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
|
||||
expect(request.postDataJSON()).toEqual({ reply: "once" })
|
||||
})
|
||||
|
||||
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||
|
||||
@@ -90,7 +90,7 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||
})
|
||||
|
||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
id: "timeline-event-7",
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/SubagentNavigation"
|
||||
@@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) {
|
||||
events,
|
||||
eventRetry: events ? 16 : undefined,
|
||||
})
|
||||
// The child session resolves via /session/:id but is absent from the /session list,
|
||||
// The child session resolves by 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 === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||
(url) => url.pathname === "/api/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([session(parentID, parentTitle, 1700000000000)]),
|
||||
body: JSON.stringify({
|
||||
data: [currentSession(session(parentID, parentTitle, 1700000000000))],
|
||||
cursor: {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await configurePage(page)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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")
|
||||
@@ -56,9 +57,14 @@ 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") return sse(route)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/session") return json(route, sessions)
|
||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
||||
if (byId) return json(route, byId)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
@@ -66,7 +72,7 @@ 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", "/session/status"].includes(url.pathname))
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname))
|
||||
return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
@@ -89,7 +95,20 @@ 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, {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -46,25 +46,30 @@ test.beforeEach(async ({ page }) => {
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
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) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
||||
}),
|
||||
)
|
||||
await page.route(`**/pty/${ptyID}`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
)
|
||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
||||
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
||||
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined)
|
||||
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
})
|
||||
@@ -95,12 +100,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("**/pty", (route) => {
|
||||
await page.route("**/api/pty?*", (route) => {
|
||||
created.count += 1
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
||||
})
|
||||
})
|
||||
await page.route(/ghostty-web/, async (route) => {
|
||||
@@ -155,27 +160,31 @@ 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("**/pty", (route) => {
|
||||
await page.route("**/api/pty?*", (route) => {
|
||||
created.count += 1
|
||||
const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
|
||||
const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2")
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(next),
|
||||
body: JSON.stringify({ location: ptyLocation(), data: next }),
|
||||
})
|
||||
})
|
||||
await page.route(`**/pty/${newPtyID}`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
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}/connect-token*`, (route) =>
|
||||
await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
||||
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
|
||||
await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
@@ -207,3 +216,11 @@ 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 }
|
||||
}
|
||||
|
||||
@@ -43,17 +43,53 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
await page.route("**/api/pty?*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
|
||||
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?*", (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.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
|
||||
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.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
@@ -29,6 +29,10 @@ 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")).toBe("e2e-ticket")
|
||||
await writeProbe(page)
|
||||
|
||||
await switchTab(page, titleB)
|
||||
@@ -85,26 +89,33 @@ async function setup(page: Page) {
|
||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
await page.route("**/api/pty?*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
|
||||
}),
|
||||
)
|
||||
await page.route(`**/pty/${ptyID}`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
)
|
||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
||||
await page.route(`**/api/pty/${ptyID}?*`, (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({ ticket: "e2e-ticket" }),
|
||||
}),
|
||||
)
|
||||
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||
})
|
||||
})
|
||||
const connections: string[] = []
|
||||
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
|
||||
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
|
||||
connections.push(ws.url())
|
||||
})
|
||||
|
||||
@@ -143,3 +154,11 @@ 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 }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
||||
|
||||
export interface MockServerConfig {
|
||||
protocol?: "v1" | "v2"
|
||||
provider: unknown
|
||||
directory: string
|
||||
project: unknown
|
||||
@@ -61,19 +60,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
route,
|
||||
path === "/api/event"
|
||||
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
||||
: [
|
||||
...(path === "/global/event"
|
||||
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
|
||||
: []),
|
||||
...(events ?? []),
|
||||
],
|
||||
: events,
|
||||
config.eventRetry,
|
||||
)
|
||||
}
|
||||
if (path === "/global/health")
|
||||
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
|
||||
if (path === "/api/health" && config.protocol === "v2")
|
||||
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||
if (path === "/global/health") return json(route, { healthy: true })
|
||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||
if (path === "/permission")
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
@@ -171,12 +162,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
||||
.filter((session) => {
|
||||
const search = url.searchParams.get("search")?.toLowerCase()
|
||||
return (
|
||||
!search ||
|
||||
String(session.title ?? "")
|
||||
.toLowerCase()
|
||||
.includes(search)
|
||||
)
|
||||
return !search || String(session.title ?? "").toLowerCase().includes(search)
|
||||
})
|
||||
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
|
||||
const data = ordered.slice(offset, offset + limit)
|
||||
@@ -190,9 +176,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
|
||||
return json(route, {
|
||||
data: Object.fromEntries(
|
||||
Object.entries(statuses).flatMap(([id, status]) =>
|
||||
status.type === "idle" ? [] : [[id, { type: "running" }]],
|
||||
),
|
||||
Object.entries(statuses).flatMap(([id, status]) => (status.type === "idle" ? [] : [[id, { type: "running" }]])),
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -305,8 +289,7 @@ function currentPermission(value: unknown) {
|
||||
resources: permission.patterns ?? [],
|
||||
save: permission.always,
|
||||
metadata: permission.metadata,
|
||||
source:
|
||||
tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
|
||||
source: tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,8 +104,7 @@ export async function installSseTransport<T>(
|
||||
created: Date.now(),
|
||||
type: payload.type,
|
||||
data: payload.properties ?? {},
|
||||
location:
|
||||
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
||||
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
||||
}
|
||||
}
|
||||
const acknowledge = (
|
||||
@@ -161,7 +160,9 @@ export async function installSseTransport<T>(
|
||||
})
|
||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||
if (input.burst) {
|
||||
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
|
||||
const bytes = encoder.encode(
|
||||
encoded.map((item) => frame(item.payload, item.delivery.options)).join(""),
|
||||
)
|
||||
connection.controller.enqueue(bytes)
|
||||
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
||||
}
|
||||
@@ -194,15 +195,9 @@ export async function installSseTransport<T>(
|
||||
connections.push(record)
|
||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
||||
if (url.pathname === "/api/event")
|
||||
controller.enqueue(
|
||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||
)
|
||||
if (url.pathname === "/global/event")
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
frame({
|
||||
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
|
||||
}),
|
||||
frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} }),
|
||||
),
|
||||
)
|
||||
request.signal.addEventListener(
|
||||
|
||||
+11
-52
@@ -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 { currentRoute, LayoutProvider } from "@/context/layout"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
@@ -67,8 +67,7 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref }
|
||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||
|
||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||
import { NewHome } from "@/pages/home"
|
||||
import { LegacyHome } from "@/pages/home/legacy-home"
|
||||
import { NewHome, LegacyHome } from "@/pages/home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
|
||||
@@ -237,44 +236,6 @@ function UiI18nBridge(props: ParentProps) {
|
||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||
}
|
||||
|
||||
function LayoutCompatibility(props: ParentProps) {
|
||||
const global = useGlobal()
|
||||
const location = useLocation()
|
||||
const server = useServer()
|
||||
const settings = useSettings()
|
||||
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()
|
||||
})
|
||||
|
||||
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 {
|
||||
interface Window {
|
||||
__OPENCODE__?: {
|
||||
@@ -596,17 +557,15 @@ export function AppInterface(props: {
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<LayoutCompatibility>
|
||||
<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>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
|
||||
@@ -13,6 +14,7 @@ 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
|
||||
@@ -145,7 +147,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||
opened: serverCtx.projects.list,
|
||||
stored: () => serverCtx.sync.data.project,
|
||||
load: (search, signal) =>
|
||||
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
|
||||
serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
})
|
||||
@@ -219,7 +221,7 @@ export function createServerSessionEntries(props: {
|
||||
server: ServerConnection.Key
|
||||
opened: () => LocalProject[]
|
||||
stored: () => Project[]
|
||||
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
|
||||
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
|
||||
untitled: () => string
|
||||
category: () => string
|
||||
}) {
|
||||
@@ -255,7 +257,8 @@ export function createServerSessionEntries(props: {
|
||||
return props
|
||||
.load(search, current.signal)
|
||||
.then((result) =>
|
||||
(result.data ?? [])
|
||||
result.data
|
||||
.map(normalizeSessionInfo)
|
||||
.filter((session) => !session.time.archived)
|
||||
.map((session) => {
|
||||
const project =
|
||||
@@ -264,7 +267,7 @@ export function createServerSessionEntries(props: {
|
||||
id: `session:${props.server}:${session.id}`,
|
||||
type: "session" as const,
|
||||
title: session.title || props.untitled(),
|
||||
description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
|
||||
description: project ? displayName(project) : getFilename(session.directory),
|
||||
category: props.category(),
|
||||
directory: session.directory,
|
||||
sessionID: session.id,
|
||||
|
||||
@@ -80,7 +80,7 @@ export function DialogHomeCommandPaletteV2(props: {
|
||||
opened: serverCtx.projects.list,
|
||||
stored: () => serverCtx.sync.data.project,
|
||||
load: (search, signal) =>
|
||||
serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
|
||||
serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
IntegrationMethod,
|
||||
IntegrationOauthConnectOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
@@ -28,6 +31,8 @@ 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"
|
||||
@@ -35,8 +40,11 @@ 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 })
|
||||
@@ -228,8 +236,6 @@ function ProviderPickerV2(props: {
|
||||
}) {
|
||||
const providers = useProviders(props.directory)
|
||||
const language = useLanguage()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const [store, setStore] = createStore({
|
||||
filter: "",
|
||||
active: undefined as string | undefined,
|
||||
@@ -266,19 +272,7 @@ function ProviderPickerV2(props: {
|
||||
|
||||
const connect = (provider: string) => {
|
||||
props.onPrepare?.()
|
||||
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))
|
||||
props.onSelect(provider)
|
||||
}
|
||||
|
||||
const move = (event: KeyboardEvent, direction: number) => {
|
||||
@@ -395,10 +389,17 @@ 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 alive = { value: true }
|
||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
@@ -413,38 +414,34 @@ function ProviderConnection(props: {
|
||||
const provider = createMemo(
|
||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||
)
|
||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||
const fallback = createMemo<ConnectMethod[]>(() => [
|
||||
{
|
||||
type: "api" as const,
|
||||
type: "key" as const,
|
||||
label: language.t("provider.connect.method.apiKey"),
|
||||
},
|
||||
])
|
||||
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 [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 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 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 [store, setStore] = createStore({
|
||||
methodIndex: directMethod as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
|
||||
promptInputs: undefined as undefined | Record<string, string>,
|
||||
state: (directMethod === undefined ? "pending" : undefined) as
|
||||
| undefined
|
||||
| "pending"
|
||||
| "complete"
|
||||
| "error"
|
||||
| "prompt",
|
||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
@@ -454,7 +451,7 @@ function ProviderConnection(props: {
|
||||
| { type: "auth.prompt" }
|
||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
|
||||
| { type: "auth.error"; error: string }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
@@ -508,7 +505,7 @@ function ProviderConnection(props: {
|
||||
|
||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||
if (!value) return ""
|
||||
if (value.type === "api") return language.t("provider.connect.method.apiKey")
|
||||
if (value.type === "key") return language.t("provider.connect.method.apiKey")
|
||||
return value.label ?? ""
|
||||
}
|
||||
|
||||
@@ -518,7 +515,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 === "api" ? "Browser" : undefined,
|
||||
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,46 +546,22 @@ 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()
|
||||
.client.provider.oauth.authorize(
|
||||
{
|
||||
providerID: props.provider,
|
||||
method: index,
|
||||
inputs,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.api.integration.oauth.connect({
|
||||
integrationID: props.provider,
|
||||
methodID: method.id,
|
||||
inputs: inputs ?? {},
|
||||
location: location(),
|
||||
})
|
||||
.then((x) => {
|
||||
if (!alive.value) return
|
||||
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! })
|
||||
dispatch({ type: "auth.complete", authorization: x.data })
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive.value) return
|
||||
@@ -603,9 +576,9 @@ function ProviderConnection(props: {
|
||||
index: 0,
|
||||
})
|
||||
|
||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||
const prompts = createMemo(() => {
|
||||
const value = method()
|
||||
return value?.prompts ?? []
|
||||
return value?.type === "oauth" ? (value.prompts ?? []) : []
|
||||
})
|
||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||
if (!prompt.when) return true
|
||||
@@ -636,10 +609,6 @@ function ProviderConnection(props: {
|
||||
setFormStore("index", next)
|
||||
return
|
||||
}
|
||||
if (method()?.type === "api") {
|
||||
dispatch({ type: "auth.inputs", inputs: value })
|
||||
return
|
||||
}
|
||||
await selectMethod(store.methodIndex, value)
|
||||
}
|
||||
|
||||
@@ -741,7 +710,10 @@ function ProviderConnection(props: {
|
||||
})
|
||||
|
||||
async function complete() {
|
||||
await serverSDK().client.global.dispose()
|
||||
const value = directory()
|
||||
await queryClient
|
||||
.refetchQueries(serverSync().queryOptions.providers(value ? pathKey(value) : null))
|
||||
.catch(() => undefined)
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
@@ -805,7 +777,7 @@ function ProviderConnection(props: {
|
||||
listRef = ref
|
||||
}}
|
||||
items={methods}
|
||||
key={(m) => m?.label}
|
||||
key={(m) => m?.label ?? m?.type}
|
||||
onSelect={async (selected, index) => {
|
||||
if (!selected) return
|
||||
void selectMethod(index)
|
||||
@@ -851,13 +823,10 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
await serverSDK().client.auth.set({
|
||||
providerID: props.provider,
|
||||
auth: {
|
||||
type: "api",
|
||||
key: apiKey,
|
||||
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
||||
},
|
||||
await serverSDK().api.integration.connect.key({
|
||||
integrationID: props.provider,
|
||||
location: location(),
|
||||
key: apiKey,
|
||||
})
|
||||
await complete()
|
||||
}
|
||||
@@ -984,12 +953,13 @@ function ProviderConnection(props: {
|
||||
|
||||
setFormStore("error", undefined)
|
||||
const result = await serverSDK()
|
||||
.client.provider.oauth.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
.api.integration.oauth.complete({
|
||||
integrationID: props.provider,
|
||||
attemptID: store.authorization!.attemptID,
|
||||
location: location(),
|
||||
code,
|
||||
})
|
||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||
.then(() => ({ ok: true as const }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (result.ok) {
|
||||
await complete()
|
||||
@@ -1076,25 +1046,37 @@ function ProviderConnection(props: {
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
const poll = async () => {
|
||||
const authorization = store.authorization
|
||||
if (!authorization || !alive.value) return
|
||||
const result = await serverSDK()
|
||||
.client.provider.oauth.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
.api.integration.oauth.status({
|
||||
integrationID: props.provider,
|
||||
attemptID: authorization.attemptID,
|
||||
location: location(),
|
||||
})
|
||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||
.then((value) => ({ ok: true as const, status: value.data }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
|
||||
if (!alive.value) return
|
||||
|
||||
if (!result.ok) {
|
||||
const message = formatError(result.error, language.t("common.requestFailed"))
|
||||
dispatch({ type: "auth.error", error: message })
|
||||
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
|
||||
return
|
||||
}
|
||||
|
||||
await complete()
|
||||
})()
|
||||
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()
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -1178,15 +1160,15 @@ function ProviderConnection(props: {
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={method()?.type === "api"}>
|
||||
<Match when={method()?.type === "key"}>
|
||||
<ApiAuthView />
|
||||
</Match>
|
||||
<Match when={method()?.type === "oauth"}>
|
||||
<Switch>
|
||||
<Match when={store.authorization?.method === "code"}>
|
||||
<Match when={store.authorization?.mode === "code"}>
|
||||
<OAuthCodeView />
|
||||
</Match>
|
||||
<Match when={store.authorization?.method === "auto"}>
|
||||
<Match when={store.authorization?.mode === "auto"}>
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -131,7 +131,6 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
|
||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||
|
||||
|
||||
@@ -69,15 +69,11 @@ export const DialogFork: Component = () => {
|
||||
const dir = base64Encode(sdk().directory)
|
||||
|
||||
sdk()
|
||||
.client.session.fork({ sessionID, messageID: item.id })
|
||||
.api.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.data.id })
|
||||
navigate(`/${dir}/session/${forked.data.id}`)
|
||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||
navigate(`/${dir}/session/${forked.id}`)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
@@ -28,6 +28,7 @@ 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
|
||||
@@ -68,9 +69,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
() =>
|
||||
sdk.client.path
|
||||
sdk.api.path
|
||||
.get()
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined),
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
@@ -85,18 +85,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
)
|
||||
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
||||
const [suggestions] = createResource(input, async (value) => {
|
||||
const typed = cleanPickerInput(value).replace(/\/+$/, "")
|
||||
const cleaned = cleanPickerInput(value)
|
||||
const typed = cleaned.replace(/\/+$/, "")
|
||||
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
||||
if (!typed || typed === current) return { query: value, items: [] }
|
||||
if (!cleaned || (root() && 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 files = await sdk.client.find
|
||||
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
|
||||
.then((result) => result.data ?? [])
|
||||
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)
|
||||
.catch(() => [])
|
||||
const results = [
|
||||
...directories,
|
||||
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
|
||||
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
|
||||
]
|
||||
return {
|
||||
query: value,
|
||||
@@ -115,9 +123,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
existing ??
|
||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||
return sdk.client.file
|
||||
.list({ directory: absolute, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
return sdk.api.file
|
||||
.list({ location: { directory: absolute } })
|
||||
.then((result) =>
|
||||
result.data.map((entry) => ({
|
||||
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
|
||||
type: entry.type,
|
||||
})),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
})
|
||||
listings.set(key, request)
|
||||
|
||||
@@ -60,9 +60,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
async () => {
|
||||
return sdk.client.path
|
||||
return sdk.api.path
|
||||
.get()
|
||||
.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 || toggle.isPending) return
|
||||
if (!x || x.status === "pending" || toggle.isPending) return
|
||||
toggle.mutate(x.name)
|
||||
}}
|
||||
>
|
||||
@@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={toggle.isPending && toggle.variables === i.name}
|
||||
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
|
||||
onChange={() => {
|
||||
if (toggle.isPending) return
|
||||
toggle.mutate(i.name)
|
||||
|
||||
@@ -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 = {
|
||||
client: {
|
||||
find: {
|
||||
files: (input: { directory: string }) => {
|
||||
directories.push(input.directory)
|
||||
api: {
|
||||
file: {
|
||||
find: (input: { location?: { directory?: string } }) => {
|
||||
directories.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
},
|
||||
@@ -152,6 +152,29 @@ 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,14 +342,17 @@ 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.client.file
|
||||
.list({ directory: key, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
const request = args.sdk.api.file
|
||||
.list({ location: { directory: key } })
|
||||
.then((result) => result.data)
|
||||
.catch(() => [])
|
||||
.then((nodes) =>
|
||||
nodes
|
||||
.filter((node) => node.type === "directory")
|
||||
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
|
||||
.map((node) => {
|
||||
const relative = trimPickerPath(normalizePickerDrive(node.path))
|
||||
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
|
||||
}),
|
||||
)
|
||||
cache.set(key, request)
|
||||
return request
|
||||
@@ -371,9 +374,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.client.find
|
||||
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
||||
.then((result) => result.data ?? [])
|
||||
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))
|
||||
.catch(() => [])
|
||||
if (!active()) return []
|
||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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"
|
||||
@@ -70,13 +71,15 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await serverCtx().sdk.client.project.update({
|
||||
const project = await serverCtx().sdk.api.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||
commands: { start },
|
||||
})
|
||||
serverCtx().sync.set("project", (items) =>
|
||||
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
|
||||
)
|
||||
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
|
||||
@@ -7,6 +7,11 @@ 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
|
||||
@@ -19,11 +24,15 @@ 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: string[] = []
|
||||
const sentShell: Array<{ sessionID: string; id?: string; command: 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 } = {}
|
||||
@@ -32,7 +41,7 @@ let variant: string | undefined
|
||||
let permissionServer = "server-a"
|
||||
let createSessionGate: Promise<void> | undefined
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||
prompt: promptValue,
|
||||
cursor: 0,
|
||||
@@ -64,23 +73,39 @@ const prompt = {
|
||||
const clientFor = (directory: string) => {
|
||||
createdClients.push(directory)
|
||||
return {
|
||||
session: {
|
||||
create: async () => {
|
||||
await createSessionGate
|
||||
createdSessions.push(directory)
|
||||
return {
|
||||
data: {
|
||||
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)
|
||||
},
|
||||
},
|
||||
shell: async () => {
|
||||
sentShell.push(directory)
|
||||
return { data: undefined }
|
||||
},
|
||||
prompt: async () => ({ data: undefined }),
|
||||
promptAsync: async () => ({ data: undefined }),
|
||||
},
|
||||
session: {
|
||||
command: async () => ({ data: undefined }),
|
||||
abort: async () => ({ data: undefined }),
|
||||
},
|
||||
@@ -90,27 +115,6 @@ 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")
|
||||
|
||||
@@ -193,8 +197,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)
|
||||
@@ -206,7 +210,7 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
useSync: () => () => ({
|
||||
data: { command: [] },
|
||||
data: { command: commands },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
@@ -233,6 +237,9 @@ beforeAll(async () => {
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
sync: async () => {
|
||||
serverSessionSyncs++
|
||||
},
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedDirectories.push(directory)
|
||||
@@ -274,11 +281,17 @@ 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
|
||||
@@ -287,8 +300,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", () => {
|
||||
@@ -321,8 +334,24 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sentShell).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(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" },
|
||||
@@ -443,6 +472,7 @@ 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({
|
||||
@@ -451,6 +481,53 @@ 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_")
|
||||
})
|
||||
|
||||
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 () => {
|
||||
@@ -511,7 +588,8 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
|
||||
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
|
||||
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
||||
expect(optimisticSeeded).toEqual([true])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,47 +15,9 @@ export const ServerRowMenu: Component<{
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const key = ServerConnection.key(props.server)
|
||||
return (
|
||||
<ServerRowMenuView
|
||||
server={props.server}
|
||||
labels={serverMenuLabels(language)}
|
||||
canDefault={props.controller.canDefault()}
|
||||
isDefault={props.controller.defaultKey() === key}
|
||||
onEdit={props.onEdit}
|
||||
onSetDefault={() => props.controller.setDefault(key)}
|
||||
onRemoveDefault={() => props.controller.setDefault(null)}
|
||||
onRemove={() => props.controller.handleRemove(key)}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const builtin = ServerConnection.builtin(props.server)
|
||||
const isDefault = () => props.controller.defaultKey() === key
|
||||
|
||||
export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
|
||||
return {
|
||||
more: language.t("common.moreOptions"),
|
||||
server: language.t("settings.section.server"),
|
||||
edit: language.t("dialog.server.menu.edit"),
|
||||
default: language.t("dialog.server.menu.default"),
|
||||
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
|
||||
delete: language.t("dialog.server.menu.delete"),
|
||||
}
|
||||
}
|
||||
|
||||
export const ServerRowMenuView: Component<{
|
||||
server: ServerConnection.Any
|
||||
labels: ReturnType<typeof serverMenuLabels>
|
||||
canDefault: boolean
|
||||
isDefault: boolean
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
onSetDefault: () => void
|
||||
onRemoveDefault: () => void
|
||||
onRemove: () => void
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}> = (props) => {
|
||||
const builtin = () => ServerConnection.builtin(props.server)
|
||||
const httpServer = () => (props.server.type === "http" ? props.server : undefined)
|
||||
return (
|
||||
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<MenuV2.Trigger
|
||||
@@ -63,30 +25,31 @@ export const ServerRowMenuView: Component<{
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" />}
|
||||
aria-label={props.labels.more}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
|
||||
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item
|
||||
disabled={builtin() || !httpServer()}
|
||||
onSelect={() => {
|
||||
const server = httpServer()
|
||||
if (server) props.onEdit(server)
|
||||
}}
|
||||
disabled={builtin || props.server.type !== "http"}
|
||||
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
|
||||
>
|
||||
{props.labels.edit}
|
||||
{language.t("dialog.server.menu.edit")}
|
||||
</MenuV2.Item>
|
||||
<Show when={props.canDefault && !props.isDefault}>
|
||||
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
|
||||
<Show when={props.controller.canDefault() && !isDefault()}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.canDefault && props.isDefault}>
|
||||
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
|
||||
<Show when={props.controller.canDefault() && isDefault()}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
|
||||
{props.labels.delete}
|
||||
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
|
||||
{language.t("dialog.server.menu.delete")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
</MenuV2.Content>
|
||||
|
||||
@@ -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 { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useUpdaterAction } from "./updater-action"
|
||||
import {
|
||||
monoDefault,
|
||||
@@ -125,14 +125,12 @@ export const SettingsGeneral: Component = () => {
|
||||
|
||||
const serverSync = useServerSync()
|
||||
const serverSdk = useServerSDK()
|
||||
const protocol = useServerProtocol()
|
||||
|
||||
const [shells] = createResource(
|
||||
() => (protocol() === "v1" ? serverSdk() : undefined),
|
||||
(sdk) =>
|
||||
sdk
|
||||
.client.pty.shells()
|
||||
.then((res) => res.data ?? [])
|
||||
() =>
|
||||
serverSdk()
|
||||
.api.pty.shells()
|
||||
.then((res) => res.data)
|
||||
.catch(() => [] as ShellOption[]),
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
@@ -324,29 +322,27 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
</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.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>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
@@ -39,7 +39,6 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const protocol = useServerProtocol()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders()
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
@@ -84,8 +83,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
const canDisconnect = (item: ProviderItem) =>
|
||||
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
|
||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
@@ -98,7 +96,6 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
if (protocol() !== "v1") return
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
@@ -221,33 +218,31 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={protocol() === "v1"}>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
|
||||
data-component="custom-provider-section"
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</div>
|
||||
<span class="text-12-regular text-text-weak pl-8">
|
||||
{language.t("settings.providers.custom.description")}
|
||||
</span>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
|
||||
data-component="custom-provider-section"
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</div>
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
</Button>
|
||||
<span class="text-12-regular text-text-weak pl-8">
|
||||
{language.t("settings.providers.custom.description")}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsList>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -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 { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useUpdaterAction } from "../updater-action"
|
||||
import {
|
||||
monoDefault,
|
||||
@@ -92,7 +92,6 @@ 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()
|
||||
@@ -123,11 +122,10 @@ export const SettingsGeneralV2: Component<{
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const [shells] = createResource(
|
||||
() => (protocol() === "v1" ? serverSdk() : undefined),
|
||||
(sdk) =>
|
||||
sdk
|
||||
.client.pty.shells()
|
||||
.then((res) => res.data ?? [])
|
||||
() =>
|
||||
serverSdk()
|
||||
.api.pty.shells()
|
||||
.then((res) => res.data)
|
||||
.catch(() => [] as ShellOption[]),
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
@@ -283,28 +281,26 @@ export const SettingsGeneralV2: Component<{
|
||||
</div>
|
||||
</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.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>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
@@ -696,9 +692,7 @@ export const SettingsGeneralV2: Component<{
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||
<Show when={!settings.general.newLayoutRequired()}>
|
||||
<InterfaceSection />
|
||||
</Show>
|
||||
<InterfaceSection />
|
||||
</Show>
|
||||
|
||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||
@@ -33,7 +33,6 @@ 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()
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
@@ -78,8 +77,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
const canDisconnect = (item: ProviderItem) =>
|
||||
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
|
||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
@@ -92,7 +90,6 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
if (protocol() !== "v1") return
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
@@ -221,35 +218,33 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={protocol() === "v1"}>
|
||||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||
<div class="settings-v2-provider-lead">
|
||||
<ProviderIcon
|
||||
id="synthetic"
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-provider-icon shrink-0"
|
||||
/>
|
||||
<div class="settings-v2-provider-copy">
|
||||
<div class="settings-v2-provider-main">
|
||||
<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>
|
||||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||
<div class="settings-v2-provider-lead">
|
||||
<ProviderIcon
|
||||
id="synthetic"
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-provider-icon shrink-0"
|
||||
/>
|
||||
<div class="settings-v2-provider-copy">
|
||||
<div class="settings-v2-provider-main">
|
||||
<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>
|
||||
</div>
|
||||
<ButtonV2
|
||||
size="normal"
|
||||
variant="neutral"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</Show>
|
||||
<ButtonV2
|
||||
size="normal"
|
||||
variant="neutral"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</SettingsListV2>
|
||||
|
||||
<button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}>
|
||||
|
||||
@@ -16,7 +16,6 @@ import { type ServerHealth } from "@/utils/server-health"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { useServerProtocol } from "@/context/server-sdk"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -258,7 +257,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const settings = useSettings()
|
||||
const protocol = useServerProtocol()
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
showToast({
|
||||
@@ -317,12 +315,10 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||
{language.t("status.popover.tab.lsp")}
|
||||
</Tabs.Trigger>
|
||||
<Show when={protocol() === "v1"}>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||
{language.t("status.popover.tab.plugins")}
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||
{language.t("status.popover.tab.plugins")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
{!settings.general.newLayoutDesigns() && (
|
||||
@@ -482,27 +478,25 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Show when={protocol() === "v1"}>
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={plugins().length > 0}
|
||||
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
||||
>
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
||||
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={plugins().length > 0}
|
||||
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
||||
>
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
||||
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ import { matchKeybind, parseKeybind } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { terminalFontFamily, useSettings } from "@/context/settings"
|
||||
import type { LocalPTY } from "@/context/terminal"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
@@ -176,15 +175,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
|
||||
const connection = useServerSDK()().server
|
||||
const directory = sdk().directory
|
||||
const client = sdk().client
|
||||
const client = sdk().api.pty
|
||||
const url = sdk().url
|
||||
const auth = connection.http
|
||||
const username = auth?.username ?? "opencode"
|
||||
const password = auth?.password ?? ""
|
||||
const authToken = connection.type === "http" ? connection.authToken : false
|
||||
const sameOrigin = new URL(url, location.href).origin === location.origin
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, [
|
||||
"pty",
|
||||
@@ -242,9 +235,10 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const pushSize = (cols: number, rows: number) => {
|
||||
return client.pty
|
||||
return client
|
||||
.update({
|
||||
ptyID: id,
|
||||
location: { directory },
|
||||
size: { cols, rows },
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -523,33 +517,29 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const gone = () =>
|
||||
client.pty
|
||||
.get({ ptyID: id }, { throwOnError: false })
|
||||
.then((result) => result.response.status === 404)
|
||||
client
|
||||
.get({ ptyID: id, location: { directory } })
|
||||
.then((result) => result.data.status === "exited")
|
||||
.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 () => {
|
||||
const result = await client.pty
|
||||
.connectToken(
|
||||
{ ptyID: id, directory },
|
||||
{
|
||||
throwOnError: false,
|
||||
headers: { "x-opencode-ticket": "1" },
|
||||
},
|
||||
)
|
||||
return client
|
||||
.connectToken({
|
||||
ptyID: id,
|
||||
location: { directory },
|
||||
"x-opencode-ticket": "1",
|
||||
})
|
||||
.then((result) => result.data.ticket)
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error && err.message.includes("Request is not supported")) return
|
||||
if (err && typeof err === "object" && "_tag" in err && err._tag === "ForbiddenError") {
|
||||
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
||||
}
|
||||
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) => {
|
||||
@@ -579,7 +569,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
fail(err)
|
||||
return undefined
|
||||
})
|
||||
if (once.value) return
|
||||
if (!ticket || once.value) return
|
||||
if (disposed) return
|
||||
|
||||
const socket = new WebSocket(
|
||||
@@ -589,10 +579,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
directory,
|
||||
cursor: seek,
|
||||
ticket,
|
||||
sameOrigin,
|
||||
username,
|
||||
password,
|
||||
authToken,
|
||||
}),
|
||||
)
|
||||
socket.binaryType = "arraybuffer"
|
||||
|
||||
@@ -120,8 +120,7 @@ export function TabNavItem(props: {
|
||||
const ctx = serverCtx()
|
||||
const session = props.session()
|
||||
if (!ctx || !session) return
|
||||
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
|
||||
await client.session.update({ sessionID: session.id, title })
|
||||
await ctx.sdk.api.session.rename({ sessionID: session.id, title })
|
||||
}
|
||||
|
||||
const closeRename = async (save: boolean) => {
|
||||
|
||||
@@ -28,6 +28,7 @@ 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>
|
||||
@@ -267,9 +268,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
||||
},
|
||||
({ route, sdk }) =>
|
||||
sdk.client.session
|
||||
sdk.api.session
|
||||
.get({ sessionID: route.sessionId })
|
||||
.then((x) => x.data)
|
||||
.then(normalizeSessionInfo)
|
||||
.catch(() => {}),
|
||||
)
|
||||
|
||||
|
||||
@@ -204,10 +204,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
||||
sdk()
|
||||
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
|
||||
serverSDK()
|
||||
.api.file.find(
|
||||
{
|
||||
location: { directory: sdk().directory },
|
||||
query,
|
||||
type: dirs === "true" ? "directory" : "file",
|
||||
limit: options?.limit,
|
||||
},
|
||||
{ signal: options?.signal },
|
||||
)
|
||||
.then(
|
||||
(x) => (x.data ?? []).map(path.normalize),
|
||||
(x) => x.data.map((entry) => path.normalize(entry.path)),
|
||||
(error) => {
|
||||
if (options?.signal?.aborted) throw error
|
||||
return []
|
||||
|
||||
@@ -124,6 +124,7 @@ describe("bootstrapDirectory", () => {
|
||||
expect(store.status).toBe("complete")
|
||||
expect(mcpReads).toEqual([])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe("query keys", () => {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
AgentListOutput,
|
||||
ModelDefaultOutput,
|
||||
ModelListOutput,
|
||||
ProviderListOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { AgentListOutput, ModelDefaultOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
|
||||
import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
|
||||
|
||||
describe("normalizeAgentList", () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ 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"
|
||||
@@ -126,7 +127,7 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
||||
const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
||||
const parts = pathname.split("/").filter(Boolean)
|
||||
if (parts.length === 0) return { type: "home" }
|
||||
|
||||
@@ -570,7 +571,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
void serverSdk()
|
||||
.client.project.update({ projectID: project.id, directory: worktree, icon: { color } })
|
||||
.api.project.update({ projectID: project.id, icon: { color } })
|
||||
.then((result) =>
|
||||
serverSync().set("project", (items) =>
|
||||
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
|
||||
),
|
||||
)
|
||||
.catch(() => {
|
||||
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ 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,
|
||||
@@ -211,7 +212,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||
)
|
||||
|
||||
function enableConfiguredDirectory(directory: string) {
|
||||
if (input.sdk.protocolKind() !== "v1") return
|
||||
if (meta.disposed || !ready()) return
|
||||
const [childStore] = input.sync.child(directory)
|
||||
if (childStore.config.permission !== "allow") return
|
||||
@@ -244,9 +244,20 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||
|
||||
const respond: PermissionRespondFn = (request) => {
|
||||
if (meta.disposed) return
|
||||
input.sdk.client.permission.respond(request).catch(() => {
|
||||
responded.delete(request.permissionID)
|
||||
})
|
||||
input.sdk.api.permission
|
||||
.reply({ sessionID: request.sessionID, requestID: request.permissionID, reply: request.response })
|
||||
.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))
|
||||
}
|
||||
|
||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||
@@ -344,14 +355,12 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||
}),
|
||||
)
|
||||
|
||||
input.sdk.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
list(directory)
|
||||
.then((permissions) => {
|
||||
if (meta.disposed) return
|
||||
if (!isAutoAcceptingDirectory(directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory))
|
||||
for (const permission of permissions) {
|
||||
void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory))
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
@@ -378,16 +387,14 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||
}),
|
||||
)
|
||||
|
||||
input.sdk.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
list(directory)
|
||||
.then((permissions) => {
|
||||
if (meta.disposed) return
|
||||
if (enableVersion.get(key) !== version) return
|
||||
if (!isAutoAccepting(sessionID, directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
for (const permission of permissions) {
|
||||
void respondPending(
|
||||
perm,
|
||||
permission,
|
||||
directory,
|
||||
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
|
||||
)
|
||||
|
||||
@@ -53,14 +53,13 @@ describe("coalesceServerEvents", () => {
|
||||
})
|
||||
|
||||
test("merges adjacent current text deltas", () => {
|
||||
const current = (id: string, value: string) =>
|
||||
adaptServerEvent({
|
||||
id,
|
||||
created: 1,
|
||||
type: "session.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
|
||||
} as OpenCodeEvent)
|
||||
const current = (id: string, value: string) => adaptServerEvent({
|
||||
id,
|
||||
created: 1,
|
||||
type: "session.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
|
||||
} as OpenCodeEvent)
|
||||
const result = coalesceServerEvents([
|
||||
{ directory: "/repo", payload: current("evt_1", "hello ") },
|
||||
{ directory: "/repo", payload: current("evt_2", "world") },
|
||||
|
||||
@@ -3,16 +3,16 @@ import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { type Accessor, batch, createMemo, createResource, onCleanup, onMount } from "solid-js"
|
||||
import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server"
|
||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createApiForServer, createSdkForServer } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { createRefCountMap } from "@/utils/refcount"
|
||||
import { useGlobal } from "./global"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
|
||||
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
|
||||
import { detectServerProtocol } from "@/utils/server-protocol"
|
||||
import { createCompatibleApi } from "@/utils/server-compat"
|
||||
|
||||
const isAbortError = (error: unknown) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
@@ -164,27 +164,7 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
|
||||
start()
|
||||
}
|
||||
|
||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
||||
type ServerSDKBase = {
|
||||
server: ServerConnection.Any
|
||||
scope: ServerScope
|
||||
protocol: Promise<ServerProtocol>
|
||||
protocolKind: Accessor<ServerProtocol | undefined>
|
||||
url: string
|
||||
client: ReturnType<typeof createSdkForServer>
|
||||
api: CompatibleApi
|
||||
currentApi: ServerApi
|
||||
event: {
|
||||
on: ServerEventEmitter["on"]
|
||||
listen: ServerEventEmitter["listen"]
|
||||
start: () => Promise<void> | undefined
|
||||
}
|
||||
createClient: (
|
||||
opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">,
|
||||
) => ReturnType<typeof createSdkForServer>
|
||||
}
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
|
||||
const platform = usePlatform()
|
||||
const abort = new AbortController()
|
||||
|
||||
@@ -206,10 +186,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
server: server.http,
|
||||
})
|
||||
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
|
||||
const [protocolKind] = createResource(
|
||||
() => protocol,
|
||||
(value) => value,
|
||||
)
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: ServerEvent
|
||||
}>()
|
||||
@@ -338,7 +314,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
fetch: platform.fetch,
|
||||
throwOnError: true,
|
||||
})
|
||||
const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
const currentApi = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
const legacy = (directory?: string) =>
|
||||
createSdkForServer({
|
||||
server: server.http,
|
||||
@@ -352,7 +328,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
server,
|
||||
scope,
|
||||
protocol,
|
||||
protocolKind,
|
||||
url: server.http.url,
|
||||
client: sdk,
|
||||
api,
|
||||
@@ -372,6 +347,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
}
|
||||
}
|
||||
|
||||
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
|
||||
export type ServerSDK = ServerSDKBase & {
|
||||
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||
}
|
||||
@@ -400,11 +376,6 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
||||
},
|
||||
})
|
||||
|
||||
export function useServerProtocol() {
|
||||
const serverSDK = useServerSDK()
|
||||
return createMemo(() => serverSDK().protocolKind())
|
||||
}
|
||||
|
||||
type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
||||
}
|
||||
|
||||
@@ -25,12 +25,7 @@ describe("v2 session reducer", () => {
|
||||
input: { type: "user", delivery: "steer", data: { text: "hello" } },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
})
|
||||
apply({ ...base, id: "evt_promoted", type: "session.input.promoted", data: { sessionID: "ses_1", inputID: "msg_user" } })
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_step",
|
||||
@@ -141,15 +136,12 @@ describe("v2 session reducer", () => {
|
||||
})
|
||||
|
||||
test("requests hydration when promotion admission was missed", () => {
|
||||
const result = createV2SessionReducer().reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
const result = createV2SessionReducer().reduce([], event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}))
|
||||
|
||||
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
||||
})
|
||||
|
||||
@@ -103,63 +103,40 @@ export function createV2SessionReducer() {
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.shell.ended":
|
||||
return updateMessage<Shell>(
|
||||
source,
|
||||
(item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id,
|
||||
(item) => ({
|
||||
...item,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
output: event.data.output,
|
||||
time: { ...item.time, completed: event.created },
|
||||
}),
|
||||
sessionID,
|
||||
)
|
||||
return updateMessage<Shell>(source, (item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id, (item) => ({
|
||||
...item,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
output: event.data.output,
|
||||
time: { ...item.time, completed: event.created },
|
||||
}), sessionID)
|
||||
case "session.step.started": {
|
||||
const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
|
||||
const completed =
|
||||
current && current.id !== event.data.assistantMessageID
|
||||
? update(source, current.id, (item) =>
|
||||
item.type === "assistant"
|
||||
? { ...item, retry: undefined, time: { ...item.time, completed: event.created } }
|
||||
: item,
|
||||
)
|
||||
: [...source]
|
||||
const completed = current && current.id !== event.data.assistantMessageID
|
||||
? update(source, current.id, (item) => item.type === "assistant" ? { ...item, retry: undefined, time: { ...item.time, completed: event.created } } : item)
|
||||
: [...source]
|
||||
const existing = completed.find((item) => item.id === event.data.assistantMessageID)
|
||||
if (existing?.type === "assistant")
|
||||
return result(
|
||||
update(completed, existing.id, (item) =>
|
||||
item.type === "assistant"
|
||||
? {
|
||||
...item,
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
retry: undefined,
|
||||
error: undefined,
|
||||
finish: undefined,
|
||||
snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot,
|
||||
time: { ...item.time, completed: undefined },
|
||||
}
|
||||
: item,
|
||||
),
|
||||
current && current.id !== existing.id ? [current.id, existing.id] : [existing.id],
|
||||
)
|
||||
return result(
|
||||
[
|
||||
...completed,
|
||||
{
|
||||
id: event.data.assistantMessageID,
|
||||
type: "assistant",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
content: [],
|
||||
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
||||
time: { created: event.created },
|
||||
},
|
||||
],
|
||||
current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID],
|
||||
)
|
||||
return result(update(completed, existing.id, (item) => item.type === "assistant" ? {
|
||||
...item,
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
retry: undefined,
|
||||
error: undefined,
|
||||
finish: undefined,
|
||||
snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot,
|
||||
time: { ...item.time, completed: undefined },
|
||||
} : item), current && current.id !== existing.id ? [current.id, existing.id] : [existing.id])
|
||||
return result([...completed, {
|
||||
id: event.data.assistantMessageID,
|
||||
type: "assistant",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
content: [],
|
||||
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
||||
time: { created: event.created },
|
||||
}], current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID])
|
||||
}
|
||||
case "session.step.ended":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
@@ -167,10 +144,9 @@ export function createV2SessionReducer() {
|
||||
finish: event.data.finish,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
snapshot:
|
||||
event.data.snapshot || event.data.files
|
||||
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: item.snapshot,
|
||||
snapshot: event.data.snapshot || event.data.files
|
||||
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: item.snapshot,
|
||||
time: { ...item.time, completed: event.created },
|
||||
}))
|
||||
case "session.step.failed":
|
||||
@@ -181,10 +157,9 @@ export function createV2SessionReducer() {
|
||||
retry: undefined,
|
||||
cost: event.data.cost ?? item.cost,
|
||||
tokens: event.data.tokens ?? item.tokens,
|
||||
snapshot:
|
||||
event.data.snapshot || event.data.files
|
||||
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: item.snapshot,
|
||||
snapshot: event.data.snapshot || event.data.files
|
||||
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: item.snapshot,
|
||||
time: { ...item.time, completed: event.created },
|
||||
}))
|
||||
case "session.text.started":
|
||||
@@ -213,46 +188,29 @@ export function createV2SessionReducer() {
|
||||
}),
|
||||
}))
|
||||
case "session.reasoning.delta":
|
||||
return updateContent(
|
||||
source,
|
||||
event.data.assistantMessageID,
|
||||
sessionID,
|
||||
"reasoning",
|
||||
event.data.ordinal,
|
||||
(item) => ({
|
||||
...item,
|
||||
text: item.text + event.data.delta,
|
||||
}),
|
||||
)
|
||||
return updateContent(source, event.data.assistantMessageID, sessionID, "reasoning", event.data.ordinal, (item) => ({
|
||||
...item,
|
||||
text: item.text + event.data.delta,
|
||||
}))
|
||||
case "session.reasoning.ended":
|
||||
return updateContent(
|
||||
source,
|
||||
event.data.assistantMessageID,
|
||||
sessionID,
|
||||
"reasoning",
|
||||
event.data.ordinal,
|
||||
(item) => ({
|
||||
...item,
|
||||
text: event.data.text,
|
||||
state: event.data.state ?? item.state,
|
||||
time: { created: item.time?.created ?? event.created, completed: event.created },
|
||||
}),
|
||||
)
|
||||
return updateContent(source, event.data.assistantMessageID, sessionID, "reasoning", event.data.ordinal, (item) => ({
|
||||
...item,
|
||||
text: event.data.text,
|
||||
state: event.data.state ?? item.state,
|
||||
time: { created: item.time?.created ?? event.created, completed: event.created },
|
||||
}))
|
||||
case "session.tool.input.started":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID)
|
||||
? item.content
|
||||
: [
|
||||
...item.content,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: event.created },
|
||||
},
|
||||
],
|
||||
: [...item.content, {
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: event.created },
|
||||
}],
|
||||
}))
|
||||
case "session.tool.input.delta":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
|
||||
@@ -337,21 +295,12 @@ export function createV2SessionReducer() {
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.compaction.delta":
|
||||
return updateMessage<Extract<Compaction, { status: "running" }>>(
|
||||
source,
|
||||
(item): item is Extract<Compaction, { status: "running" }> =>
|
||||
item.type === "compaction" && item.status === "running",
|
||||
(item) => ({
|
||||
...item,
|
||||
summary: item.summary + event.data.text,
|
||||
}),
|
||||
sessionID,
|
||||
)
|
||||
return updateMessage<Extract<Compaction, { status: "running" }>>(source, (item): item is Extract<Compaction, { status: "running" }> => item.type === "compaction" && item.status === "running", (item) => ({
|
||||
...item,
|
||||
summary: item.summary + event.data.text,
|
||||
}), sessionID)
|
||||
case "session.compaction.ended": {
|
||||
const current = source.findLast(
|
||||
(item): item is Extract<Compaction, { status: "running" }> =>
|
||||
item.type === "compaction" && item.status === "running",
|
||||
)
|
||||
const current = source.findLast((item): item is Extract<Compaction, { status: "running" }> => item.type === "compaction" && item.status === "running")
|
||||
if (!current)
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
@@ -363,22 +312,16 @@ export function createV2SessionReducer() {
|
||||
recent: event.data.recent,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return result(
|
||||
update(source, current.id, () => ({
|
||||
...current,
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
})),
|
||||
[current.id],
|
||||
)
|
||||
return result(update(source, current.id, () => ({
|
||||
...current,
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
})), [current.id])
|
||||
}
|
||||
case "session.compaction.failed": {
|
||||
const current = source.findLast(
|
||||
(item): item is Extract<Compaction, { status: "running" }> =>
|
||||
item.type === "compaction" && item.status === "running",
|
||||
)
|
||||
const current = source.findLast((item): item is Extract<Compaction, { status: "running" }> => item.type === "compaction" && item.status === "running")
|
||||
const failed: Extract<Compaction, { status: "failed" }> = {
|
||||
id: current?.id ?? event.data.inputID ?? messageID(event.id),
|
||||
type: "compaction",
|
||||
@@ -389,10 +332,7 @@ export function createV2SessionReducer() {
|
||||
time: current?.time ?? { created: event.created },
|
||||
}
|
||||
if (!current) return append(failed)
|
||||
return result(
|
||||
update(source, current.id, () => failed),
|
||||
[failed.id],
|
||||
)
|
||||
return result(update(source, current.id, () => failed), [failed.id])
|
||||
}
|
||||
default:
|
||||
return
|
||||
@@ -422,7 +362,7 @@ function update(
|
||||
id: string,
|
||||
apply: (item: SessionMessageInfo) => SessionMessageInfo,
|
||||
) {
|
||||
return source.map((item) => (item.id === id ? apply(item) : item))
|
||||
return source.map((item) => item.id === id ? apply(item) : item)
|
||||
}
|
||||
|
||||
function updateMessage<T extends SessionMessageInfo>(
|
||||
@@ -433,11 +373,7 @@ function updateMessage<T extends SessionMessageInfo>(
|
||||
): V2SessionReduction {
|
||||
const current = source.findLast(matches)
|
||||
if (!current) return { sessionID, messages: [...source], touched: [] }
|
||||
return {
|
||||
sessionID,
|
||||
messages: update(source, current.id, (item) => (matches(item) ? apply(item) : item)),
|
||||
touched: [current.id],
|
||||
}
|
||||
return { sessionID, messages: update(source, current.id, (item) => matches(item) ? apply(item) : item), touched: [current.id] }
|
||||
}
|
||||
|
||||
function updateAssistant(
|
||||
@@ -448,7 +384,7 @@ function updateAssistant(
|
||||
): V2SessionReduction {
|
||||
return {
|
||||
sessionID,
|
||||
messages: update(source, id, (item) => (item.type === "assistant" ? apply(item) : item)),
|
||||
messages: update(source, id, (item) => item.type === "assistant" ? apply(item) : item),
|
||||
touched: source.some((item) => item.id === id && item.type === "assistant") ? [id] : [],
|
||||
}
|
||||
}
|
||||
@@ -459,9 +395,7 @@ function updateContent<T extends "text" | "reasoning">(
|
||||
sessionID: string,
|
||||
type: T,
|
||||
ordinal: number,
|
||||
apply: (
|
||||
item: Extract<Assistant["content"][number], { type: T }>,
|
||||
) => Extract<Assistant["content"][number], { type: T }>,
|
||||
apply: (item: Extract<Assistant["content"][number], { type: T }>) => Extract<Assistant["content"][number], { type: T }>,
|
||||
) {
|
||||
return updateAssistant(source, messageID, sessionID, (assistant) => {
|
||||
let index = -1
|
||||
@@ -480,13 +414,11 @@ function updateTool(
|
||||
messageID: string,
|
||||
callID: string,
|
||||
sessionID: string,
|
||||
apply: (
|
||||
item: Extract<Assistant["content"][number], { type: "tool" }>,
|
||||
) => Extract<Assistant["content"][number], { type: "tool" }>,
|
||||
apply: (item: Extract<Assistant["content"][number], { type: "tool" }>) => Extract<Assistant["content"][number], { type: "tool" }>,
|
||||
) {
|
||||
return updateAssistant(source, messageID, sessionID, (assistant) => ({
|
||||
...assistant,
|
||||
content: assistant.content.map((item) => (item.type === "tool" && item.id === callID ? apply(item) : item)),
|
||||
content: assistant.content.map((item) => item.type === "tool" && item.id === callID ? apply(item) : item),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@ import type {
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
|
||||
import {
|
||||
loadActiveSessionsQuery,
|
||||
loadMcpQuery,
|
||||
loadMcpResourcesQuery,
|
||||
seedActiveSessionStatuses,
|
||||
} from "./server-sync"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { createServerSession } from "./server-session"
|
||||
|
||||
|
||||
@@ -230,7 +230,6 @@ 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)
|
||||
@@ -251,7 +250,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
const layoutTransition = createMemo(() =>
|
||||
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
||||
)
|
||||
const preferredNewLayout = createMemo(() => {
|
||||
const newLayoutDesigns = createMemo(() => {
|
||||
if (layoutUpgrade()) return true
|
||||
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
||||
if (!layoutTransitionClassified()) {
|
||||
@@ -267,7 +266,6 @@ 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()) {
|
||||
@@ -406,12 +404,9 @@ 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 (preferredNewLayout() === next) return
|
||||
if (newLayoutDesigns() === next) return
|
||||
setStore("general", "newLayoutDesigns", next)
|
||||
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
|
||||
},
|
||||
|
||||
@@ -149,6 +149,7 @@ function createWorkspaceTerminalSession(
|
||||
scope: ServerScopeValue,
|
||||
legacySessionID?: string,
|
||||
) {
|
||||
const location = { directory: sdk.directory }
|
||||
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
@@ -240,15 +241,16 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
|
||||
const update = (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 }))
|
||||
}
|
||||
client.pty
|
||||
sdk.api.pty
|
||||
.update({
|
||||
ptyID: pty.id,
|
||||
location,
|
||||
title: pty.title,
|
||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||
})
|
||||
@@ -261,12 +263,13 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
}
|
||||
|
||||
const clone = async (client: DirectorySDK["client"], id: string) => {
|
||||
const clone = async (id: string) => {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
if (!pty) return
|
||||
const next = await client.pty
|
||||
const next = await sdk.api.pty
|
||||
.create({
|
||||
location,
|
||||
title: pty.title,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -308,17 +311,17 @@ function createWorkspaceTerminalSession(
|
||||
const nextNumber = pickNextTerminalNumber()
|
||||
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
|
||||
|
||||
sdk.client.pty
|
||||
.create({ title: defaultTitle(nextNumber) })
|
||||
.then((pty: { data?: { id?: string; title?: string } }) => {
|
||||
const id = pty.data?.id
|
||||
sdk.api.pty
|
||||
.create({ location, title: defaultTitle(nextNumber) })
|
||||
.then((pty) => {
|
||||
const id = pty.data.id
|
||||
if (!id) {
|
||||
if (focusRequest !== undefined) cancelFocus(focusRequest)
|
||||
return
|
||||
}
|
||||
const newTerminal = {
|
||||
id,
|
||||
title: pty.data?.title ?? defaultTitle(nextNumber),
|
||||
title: pty.data.title ?? defaultTitle(nextNumber),
|
||||
titleNumber: nextNumber,
|
||||
}
|
||||
batch(() => {
|
||||
@@ -335,7 +338,7 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
},
|
||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||
update(sdk.client, pty)
|
||||
update(pty)
|
||||
},
|
||||
trim(id: string) {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
@@ -350,10 +353,9 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
},
|
||||
async clone(id: string) {
|
||||
await clone(sdk.client, id)
|
||||
await clone(id)
|
||||
},
|
||||
bind() {
|
||||
const client = sdk.client
|
||||
return {
|
||||
trim(id: string) {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
@@ -361,10 +363,10 @@ function createWorkspaceTerminalSession(
|
||||
setStore("all", index, (pty) => trimTerminal(pty))
|
||||
},
|
||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||
update(client, pty)
|
||||
update(pty)
|
||||
},
|
||||
async clone(id: string) {
|
||||
await clone(client, id)
|
||||
await clone(id)
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -412,7 +414,7 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
}
|
||||
|
||||
await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => {
|
||||
await sdk.api.pty.remove({ ptyID: id, location }).catch((error: unknown) => {
|
||||
console.error("Failed to close terminal", error)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
update: async () => undefined,
|
||||
archive: 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" },
|
||||
update: async () => Promise.reject(failure),
|
||||
archive: async () => Promise.reject(failure),
|
||||
remove: () => {
|
||||
removed = true
|
||||
},
|
||||
|
||||
@@ -6,25 +6,15 @@ type HomeSession = {
|
||||
directory: string
|
||||
}
|
||||
|
||||
type SessionUpdate = {
|
||||
directory: string
|
||||
sessionID: string
|
||||
time: { archived: number }
|
||||
}
|
||||
|
||||
export async function archiveHomeSession(input: {
|
||||
server: ServerConnection.Key
|
||||
session: HomeSession
|
||||
update: (value: SessionUpdate) => Promise<unknown>
|
||||
archive: (sessionID: string) => Promise<unknown>
|
||||
remove: () => void
|
||||
onError?: (error: unknown) => void
|
||||
}) {
|
||||
await input
|
||||
.update({
|
||||
directory: input.session.directory,
|
||||
sessionID: input.session.id,
|
||||
time: { archived: Date.now() },
|
||||
})
|
||||
.archive(input.session.id)
|
||||
.then(() => {
|
||||
input.remove()
|
||||
notifySessionTabsRemoved({
|
||||
|
||||
+1908
-85
File diff suppressed because it is too large
Load Diff
@@ -1,265 +0,0 @@
|
||||
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 { 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, 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 { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
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> }),
|
||||
)
|
||||
const [state] = createResource(
|
||||
() => ready.promise ?? Promise.resolve(),
|
||||
(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 {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
export type HomeProjectsController = ReturnType<typeof createHomeProjectsController>
|
||||
@@ -1,548 +0,0 @@
|
||||
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"
|
||||
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
|
||||
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
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 { 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"
|
||||
|
||||
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 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>
|
||||
servers: Accessor<ServerConnection.Any[]>
|
||||
projects: Accessor<LocalProject[]>
|
||||
recentlyClosed: Accessor<LocalProject[]>
|
||||
selection: Accessor<HomeProjectSelection>
|
||||
homedir: Accessor<string>
|
||||
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
|
||||
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
|
||||
collapsed: (server: ServerConnection.Any) => boolean
|
||||
menuOpen: (id: string) => boolean
|
||||
canDefaultServer: Accessor<boolean>
|
||||
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
|
||||
onToggleCollapsed: (server: ServerConnection.Any) => void
|
||||
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
|
||||
onOpenProjectNewSession: (server: ServerConnection.Any, directory: string) => void
|
||||
onEditProject: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
onRevealProject: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
onClearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
onCloseProject: (server: ServerConnection.Any, directory: string) => void
|
||||
onOpenSettings: () => void
|
||||
onOpenHelp: () => void
|
||||
}
|
||||
|
||||
export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
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]"
|
||||
aria-label={props.language.t("home.projects")}
|
||||
onWheel={(event) => {
|
||||
if (event.target === event.currentTarget) return
|
||||
props.onWheel(event)
|
||||
}}
|
||||
>
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||
<Show
|
||||
when={props.servers().length === 1 && !(props.projects().length === 0 && props.recentlyClosed().length > 0)}
|
||||
>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
disabled={props.serverHealth(props.servers()[0])?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.servers()[0])}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</div>
|
||||
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
|
||||
<Show
|
||||
when={props.servers().length > 1}
|
||||
fallback={
|
||||
<div class="pr-3">
|
||||
<Show
|
||||
when={props.projects().length > 0}
|
||||
fallback={<HomeProjectEmpty {...props} server={props.servers()[0]} items={props.recentlyClosed()} />}
|
||||
>
|
||||
<HomeProjectList {...props} server={props.servers()[0]} items={props.projects()} />
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-4 pr-3">
|
||||
<For each={props.servers()}>
|
||||
{(item) => {
|
||||
const projects = () => props.projectsForServer(item)
|
||||
const healthy = () => !!props.serverHealth(item)?.healthy
|
||||
const hasProjects = () => projects().length > 0
|
||||
const collapsed = () => props.collapsed(item)
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<HomeServerRow
|
||||
server={item}
|
||||
{...props}
|
||||
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} server={item} items={projects()} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</ScrollView>
|
||||
<HomeUtilityNav
|
||||
class="mb-8 mt-4 hidden shrink-0 lg:flex"
|
||||
onOpenSettings={props.onOpenSettings}
|
||||
onOpenHelp={props.onOpenHelp}
|
||||
language={props.language}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeUtilityNav(props: {
|
||||
class?: string
|
||||
onOpenSettings: () => void
|
||||
onOpenHelp: () => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
return (
|
||||
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeServerRow(props: {
|
||||
language: HomeProjectsViewProps["language"]
|
||||
projectsForServer: HomeProjectsViewProps["projectsForServer"]
|
||||
menuOpen: HomeProjectsViewProps["menuOpen"]
|
||||
canDefaultServer: HomeProjectsViewProps["canDefaultServer"]
|
||||
isDefaultServer: HomeProjectsViewProps["isDefaultServer"]
|
||||
serverMenuID: HomeProjectsViewProps["serverMenuID"]
|
||||
onFocusServer: HomeProjectsViewProps["onFocusServer"]
|
||||
onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"]
|
||||
onEditServer: HomeProjectsViewProps["onEditServer"]
|
||||
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
|
||||
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
|
||||
onSetMenuOpen: HomeProjectsViewProps["onSetMenuOpen"]
|
||||
onChooseProject: HomeProjectsViewProps["onChooseProject"]
|
||||
server: ServerConnection.Any
|
||||
selected: boolean
|
||||
collapsed: boolean
|
||||
health: ServerHealth | undefined
|
||||
}) {
|
||||
const healthy = () => !!props.health?.healthy
|
||||
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
|
||||
const menuID = () => props.serverMenuID(props.server)
|
||||
return (
|
||||
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
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="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(),
|
||||
}}
|
||||
aria-label={
|
||||
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
|
||||
}
|
||||
aria-disabled={!canToggle()}
|
||||
aria-expanded={canToggle() ? !props.collapsed : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canToggle()) return
|
||||
props.onToggleCollapsed(props.server)
|
||||
}}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<IconV2
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
class="transition-transform duration-150 ease-in-out"
|
||||
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
|
||||
/>
|
||||
</span>
|
||||
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
|
||||
<ServerHealthIndicator health={props.health} />
|
||||
</div>
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<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">
|
||||
{label()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</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.menuOpen(menuID())}
|
||||
>
|
||||
<ServerRowMenuView
|
||||
server={props.server}
|
||||
labels={serverMenuLabels(props.language)}
|
||||
canDefault={props.canDefaultServer()}
|
||||
isDefault={props.isDefaultServer(props.server)}
|
||||
onEdit={props.onEditServer}
|
||||
onSetDefault={() => props.onSetDefaultServer(props.server)}
|
||||
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
|
||||
onRemove={() => props.onRemoveServer(props.server)}
|
||||
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
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
disabled={props.health?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.server)}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type HomeProjectListProps = HomeProjectsViewProps & {
|
||||
server: ServerConnection.Any
|
||||
items: LocalProject[]
|
||||
}
|
||||
|
||||
function HomeProjectList(props: HomeProjectListProps) {
|
||||
let listRef!: HTMLDivElement
|
||||
|
||||
return (
|
||||
<DragDropProvider
|
||||
sensors={(defaults) => [
|
||||
...defaults.filter((sensor) => sensor !== PointerSensor),
|
||||
PointerSensor.configure({
|
||||
activationConstraints: (event) =>
|
||||
event.pointerType === "touch"
|
||||
? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
|
||||
: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"),
|
||||
}),
|
||||
]}
|
||||
modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
|
||||
plugins={(defaults) => [
|
||||
...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
|
||||
AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
|
||||
Feedback.configure({ dropAnimation: null }),
|
||||
]}
|
||||
onDragEnd={(event) => {
|
||||
const source = event.operation.source
|
||||
if (event.canceled || !isSortable(source)) return
|
||||
if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index)
|
||||
if (props.selection().server !== ServerConnection.key(props.server))
|
||||
props.onSelectProject(props.server, source.id.toString())
|
||||
}}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-1" ref={listRef}>
|
||||
{/* Keyed on worktree strings: the enriched project objects are
|
||||
recreated on every store or sync update, so iterating them directly
|
||||
remounts all rows — killing any in-flight drag activation (the
|
||||
row's sortable unregisters on unmount) and discarding animations.
|
||||
String keys keep row elements alive and move them on reorder. */}
|
||||
<For each={props.items.map((project) => project.worktree)}>
|
||||
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
|
||||
</For>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectSlot(
|
||||
props: HomeProjectListProps & {
|
||||
worktree: string
|
||||
index: () => number
|
||||
},
|
||||
) {
|
||||
const project = createMemo(() => props.items.find((item) => item.worktree === props.worktree))
|
||||
|
||||
return (
|
||||
<Show when={project()}>
|
||||
{(item) => (
|
||||
<HomeProjectRow
|
||||
{...props}
|
||||
project={item()}
|
||||
server={props.server}
|
||||
index={props.index}
|
||||
serverSelected={props.selection().server === ServerConnection.key(props.server)}
|
||||
selected={
|
||||
props.selection().server === ServerConnection.key(props.server) &&
|
||||
props.selection().directory === props.worktree
|
||||
}
|
||||
unseen={props.unseenCount(props.server, item())}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectEmpty(
|
||||
props: HomeProjectsViewProps & {
|
||||
server: ServerConnection.Any
|
||||
items: LocalProject[]
|
||||
},
|
||||
) {
|
||||
const unreachable = () => props.serverHealth(props.server)?.healthy === false
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-action="home-add-project-row"
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
<For each={props.items}>
|
||||
{(project) => <HomeRecentlyClosedRow {...props} project={project} server={props.server} />}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeRecentlyClosedRow(
|
||||
props: HomeProjectsViewProps & {
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
},
|
||||
) {
|
||||
const unreachable = () => props.serverHealth(props.server)?.healthy === false
|
||||
const path = () => {
|
||||
const home = props.homedir()
|
||||
const worktree = props.project.worktree
|
||||
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
|
||||
return worktree
|
||||
}
|
||||
return (
|
||||
<TooltipV2 placement="right" value={path()}>
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-recently-closed-row"
|
||||
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>
|
||||
</button>
|
||||
</TooltipV2>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectRow(
|
||||
props: HomeProjectsViewProps & {
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
index: () => number
|
||||
serverSelected: boolean
|
||||
selected: boolean
|
||||
unseen: number
|
||||
},
|
||||
) {
|
||||
const serverUnreachable = () => props.serverHealth(props.server)?.healthy === false
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.project.worktree
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
},
|
||||
})
|
||||
let pointerDownSelected: boolean | undefined
|
||||
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() }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-project-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
||||
classList={{
|
||||
"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}
|
||||
disabled={serverUnreachable()}
|
||||
onPointerDown={(event) => {
|
||||
// Same-server mouse selection happens on pointerdown (like tabs),
|
||||
// but only ever selects; selectProject toggles, and deselecting here
|
||||
// would fire on every drag before the threshold is met. Cross-server
|
||||
// selection waits for click so reordering a remote server's projects
|
||||
// does not focus that server and load its session index. Touch is
|
||||
// excluded so flick-scrolling the list cannot select rows.
|
||||
pointerDownSelected = undefined
|
||||
if (event.button !== 0 || event.pointerType === "touch") return
|
||||
if (!props.serverSelected) return
|
||||
pointerDownSelected = props.selected
|
||||
if (!props.selected) props.onSelectProject(props.server, props.project.worktree)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
// The drag sensor calls preventDefault on post-drag clicks; never
|
||||
// toggle selection as part of a reorder.
|
||||
if (event.defaultPrevented) return
|
||||
// Keyboard activation and touch taps keep the original toggle.
|
||||
if (event.detail === 0 || pointerDownSelected === undefined) {
|
||||
props.onSelectProject(props.server, props.project.worktree)
|
||||
return
|
||||
}
|
||||
// Mouse: pointerdown already selected unselected rows; a plain click
|
||||
// on an already-selected row toggles it off.
|
||||
if (pointerDownSelected) props.onSelectProject(props.server, props.project.worktree)
|
||||
pointerDownSelected = undefined
|
||||
}}
|
||||
>
|
||||
<HomeProjectAvatar project={props.project} />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
||||
</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.menuOpen(menuID())}
|
||||
>
|
||||
<MenuV2
|
||||
gutter={6}
|
||||
modal={false}
|
||||
placement="bottom-end"
|
||||
open={props.menuOpen(menuID())}
|
||||
onOpenChange={(open) => props.onSetMenuOpen(menuID(), open)}
|
||||
>
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
data-action="home-project-menu"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" />}
|
||||
aria-label={props.language.t("common.moreOptions")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
|
||||
{props.language.t("command.session.new")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
|
||||
{props.language.t("dialog.project.edit.title")}
|
||||
</MenuV2.Item>
|
||||
<Show when={props.canRevealProject(props.server)}>
|
||||
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
|
||||
{props.fileManagerActionLabel()}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item
|
||||
disabled={props.unseen === 0}
|
||||
onSelect={() => props.onClearNotifications(props.server, props.project)}
|
||||
>
|
||||
{props.language.t("sidebar.project.clearNotifications")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => props.onCloseProject(props.server, props.project.worktree)}>
|
||||
{props.language.t("common.close")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<IconButtonV2
|
||||
data-action="home-project-new-session"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="edit" />}
|
||||
aria-label={props.language.t("command.session.new")}
|
||||
onClick={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
|
||||
const name = createMemo(() => displayName(props.project))
|
||||
return (
|
||||
<ProjectAvatar
|
||||
fallback={name()}
|
||||
src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,578 +0,0 @@
|
||||
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,
|
||||
createSignal,
|
||||
type JSX,
|
||||
on,
|
||||
onCleanup,
|
||||
startTransition,
|
||||
} from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useCommand } from "@/context/command"
|
||||
import {
|
||||
loadHomeSessionIndex,
|
||||
retainHomeSessions,
|
||||
type HomeSessionEvents,
|
||||
} from "@/context/global-sync/home-session-index"
|
||||
import type { LocalProject } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
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"
|
||||
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 { 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
|
||||
projectName: string
|
||||
}
|
||||
|
||||
export type HomeSessionGroup = {
|
||||
id: "today" | "yesterday" | "older"
|
||||
title: string
|
||||
sessions: HomeSessionRecord[]
|
||||
}
|
||||
|
||||
export type OpenSessionOptions = { background?: boolean }
|
||||
|
||||
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 = projects.selectedProject()
|
||||
if (!project) return projects.projects().flatMap(directories)
|
||||
return directories(project)
|
||||
})
|
||||
const projectByID = createMemo(
|
||||
() => new Map(projects.projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
)
|
||||
const homeSessions = () => projects.focusedSync().homeSessions
|
||||
const sessionEventLoad = useQuery(() => ({
|
||||
queryKey: homeSessions().eventsKey,
|
||||
queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
|
||||
initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
|
||||
enabled: false,
|
||||
}))
|
||||
const sessionLoad = useQuery(() => ({
|
||||
queryKey: homeSessions().indexKey,
|
||||
enabled: !!projects.focusedServerCtx(),
|
||||
queryFn: async ({ signal }) => {
|
||||
const ctx = projects.focusedServerCtx()
|
||||
if (!ctx) return { sessions: [], eventSequence: 0 }
|
||||
const cache = homeSessions()
|
||||
const eventSequence = cache.eventSequence()
|
||||
const index = await loadHomeSessionIndex(
|
||||
(input, options) => ctx.sdk.client.v2.session.list(input, options),
|
||||
eventSequence,
|
||||
signal,
|
||||
)
|
||||
cache.complete(eventSequence)
|
||||
return index
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
}))
|
||||
const indexedSessions = createMemo(() =>
|
||||
retainHomeSessions(
|
||||
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
|
||||
HOME_SESSION_LIMIT,
|
||||
Date.now(),
|
||||
),
|
||||
)
|
||||
const allRecords = createMemo(() =>
|
||||
buildHomeSessionRecords({
|
||||
sessions: indexedSessions,
|
||||
projectDirectories,
|
||||
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 = projects.focusedServerCtx()
|
||||
const conn = projects.focusedServer()
|
||||
if (!ctx || !conn) return
|
||||
records()
|
||||
.slice(0, 2)
|
||||
.forEach((record) => {
|
||||
const key = `${ServerConnection.key(conn)}\0${record.session.id}`
|
||||
if (prefetched.has(key)) return
|
||||
prefetched.add(key)
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
void ctx.sync.session
|
||||
.sync(record.session.id)
|
||||
.then(() =>
|
||||
Promise.all(
|
||||
(ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) =>
|
||||
(ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => {
|
||||
if (part.type !== "text" || !part.text) return []
|
||||
return preloadMarkdown(part.text, part.id, marked)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(dispose)
|
||||
} catch {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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 = projects.focusedServer()
|
||||
if (!conn) return
|
||||
const ctx = projects.focusedServerCtx()
|
||||
if (!ctx) return
|
||||
const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
|
||||
void dialog.show(() => (
|
||||
<DialogHomeCommandPaletteV2
|
||||
server={conn}
|
||||
onSelectSession={(entry) => {
|
||||
if (!entry.sessionID || !entry.directory || !entry.server) return
|
||||
const sessionID = entry.sessionID
|
||||
const server = entry.server
|
||||
const directory = entry.project?.worktree ?? entry.directory
|
||||
ctx.projects.open(directory)
|
||||
ctx.projects.touch(directory)
|
||||
void startTransition(() => {
|
||||
const tab = tabs.addSessionTab({ server, sessionId: sessionID })
|
||||
tabs.select(tab)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))
|
||||
},
|
||||
},
|
||||
{
|
||||
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 {
|
||||
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)
|
||||
},
|
||||
},
|
||||
search: {
|
||||
value: () => state.search,
|
||||
query: search,
|
||||
placeholder: searchPlaceholder,
|
||||
open: searchOpen,
|
||||
loading: () => sessionLoad.isLoading,
|
||||
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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function directories(project: LocalProject) {
|
||||
return [project.worktree, ...(project.sandboxes ?? [])]
|
||||
}
|
||||
|
||||
function buildHomeSessionRecords(input: {
|
||||
sessions: () => Session[]
|
||||
projectDirectories: () => string[]
|
||||
projects: () => LocalProject[]
|
||||
projectByID: () => Map<string, LocalProject>
|
||||
}) {
|
||||
const directories = new Set(input.projectDirectories().map(pathKey))
|
||||
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
|
||||
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.flatMap((session) => {
|
||||
const directory = pathKey(session.directory)
|
||||
const project =
|
||||
input
|
||||
.projects()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
) ?? projectForSession(session, input.projects(), input.projectByID())
|
||||
if (!project) return []
|
||||
return { session, project, projectName: displayName(project) }
|
||||
})
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
|
||||
const now = DateTime.local()
|
||||
const yesterday = now.minus({ days: 1 })
|
||||
const todaySessions = records.filter((record) =>
|
||||
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
|
||||
)
|
||||
const yesterdaySessions = records.filter((record) =>
|
||||
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
|
||||
)
|
||||
const olderSessions = records.filter((record) => {
|
||||
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
|
||||
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
|
||||
})
|
||||
const olderTitle =
|
||||
todaySessions.length === 0 && yesterdaySessions.length === 0
|
||||
? language.t("sidebar.project.recentSessions")
|
||||
: language.t("home.sessions.group.older")
|
||||
return [
|
||||
{ id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions },
|
||||
{ id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions },
|
||||
{ id: "older" as const, title: olderTitle, sessions: olderSessions },
|
||||
].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: {
|
||||
server: Accessor<ServerConnection.Key>
|
||||
record: HomeSessionRecord
|
||||
isOpenTab: (record: HomeSessionRecord) => boolean
|
||||
render: (state: { unread: Accessor<boolean>; loading: Accessor<boolean>; open: Accessor<boolean> }) => JSX.Element
|
||||
}) {
|
||||
const avatar = useSessionTabAvatarState(
|
||||
props.server,
|
||||
() => props.record.session.directory,
|
||||
() => props.record.session.id,
|
||||
)
|
||||
return props.render({
|
||||
unread: avatar.unread,
|
||||
loading: avatar.loading,
|
||||
open: () => props.isOpenTab(props.record),
|
||||
})
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { type Accessor, createMemo, For, Show } from "solid-js"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { shouldOpenSessionInBackground } from "../home-session-open"
|
||||
import {
|
||||
HomeSessionStatusController,
|
||||
homeSessionSearchKey,
|
||||
type HomeSessionGroup,
|
||||
type HomeSessionRecord,
|
||||
type OpenSessionOptions,
|
||||
} 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.
|
||||
function isBackgroundOpen(event: MouseEvent) {
|
||||
return shouldOpenSessionInBackground({
|
||||
button: event.button,
|
||||
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
|
||||
meta: event.metaKey,
|
||||
ctrl: event.ctrlKey,
|
||||
shift: event.shiftKey,
|
||||
alt: event.altKey,
|
||||
})
|
||||
}
|
||||
|
||||
export type HomeSessionsViewProps = {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
groups: Accessor<HomeSessionGroup[]>
|
||||
loading: Accessor<boolean>
|
||||
showProjectName: Accessor<boolean>
|
||||
server: Accessor<ServerConnection.Key>
|
||||
canCreateSession: Accessor<boolean>
|
||||
searchValue: Accessor<string>
|
||||
searchPlaceholder: Accessor<string>
|
||||
searchOpen: Accessor<boolean>
|
||||
searchLoading: Accessor<boolean>
|
||||
searchResults: Accessor<HomeSessionRecord[]>
|
||||
searchActive: Accessor<string>
|
||||
searchNoResultsLabel: Accessor<string>
|
||||
titleOpacity: (id: HomeSessionGroup["id"]) => number
|
||||
isOpenTab: (record: HomeSessionRecord) => boolean
|
||||
onCreateSession: () => void
|
||||
onOpenSession: (session: Session, options?: OpenSessionOptions) => void
|
||||
onArchiveSession: (session: Session) => Promise<void>
|
||||
onSetHoverTarget: (element: HTMLElement) => void
|
||||
onSetThumbTrack: (element: HTMLDivElement) => void
|
||||
onSetContent: (element: HTMLDivElement) => void
|
||||
onSetHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => void
|
||||
onWheel: (event: WheelEvent) => void
|
||||
onSetSearchRoot: (element: HTMLDivElement) => void
|
||||
onSetSearchInput: (element: HTMLInputElement) => void
|
||||
onSetSearchList: (element: HTMLDivElement) => void
|
||||
onSearchFocus: () => void
|
||||
onSearchInput: (value: string) => void
|
||||
onSearchClose: () => void
|
||||
onSearchMove: (delta: number) => void
|
||||
onSearchSelectActive: () => void
|
||||
onSearchHighlight: (record: HomeSessionRecord) => void
|
||||
onSearchSelect: (record: HomeSessionRecord, options?: OpenSessionOptions) => void
|
||||
}
|
||||
|
||||
export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
return (
|
||||
<section
|
||||
ref={props.onSetHoverTarget}
|
||||
class="min-h-0 min-w-0 flex-1 flex flex-col"
|
||||
aria-label={props.language.t("sidebar.project.recentSessions")}
|
||||
>
|
||||
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
|
||||
<HomeSessionSearch {...props} />
|
||||
<Show when={props.groups().length > 0 && props.canCreateSession()}>
|
||||
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
||||
<ButtonV2
|
||||
data-action="home-new-session"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
||||
onClick={props.onCreateSession}
|
||||
>
|
||||
{props.language.t("command.session.new")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
|
||||
<div
|
||||
ref={props.onSetThumbTrack}
|
||||
data-component="home-session-scroll-track"
|
||||
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
|
||||
/>
|
||||
</div>
|
||||
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
|
||||
<Show
|
||||
when={!props.loading()}
|
||||
fallback={
|
||||
<div class="pt-3">
|
||||
<HomeSessionSkeleton label={props.language.t("common.loading")} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.groups().length > 0}
|
||||
fallback={
|
||||
<HomeSessionsEmpty
|
||||
onNewSession={props.canCreateSession() ? props.onCreateSession : undefined}
|
||||
language={props.language}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
|
||||
<For each={props.groups()}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
<HomeSessionGroupHeader
|
||||
title={group.title}
|
||||
titleOpacity={props.titleOpacity(group.id)}
|
||||
onSetRef={(element) => props.onSetHeader(group.id, element)}
|
||||
elevated={index() === 0}
|
||||
/>
|
||||
<div
|
||||
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups().length - 1 ? "" : "mb-6"}`}
|
||||
>
|
||||
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionLeadingController(props: {
|
||||
server: HomeSessionsViewProps["server"]
|
||||
isOpenTab: HomeSessionsViewProps["isOpenTab"]
|
||||
record: HomeSessionRecord
|
||||
revealProjectOnHover: boolean
|
||||
}) {
|
||||
return (
|
||||
<HomeSessionStatusController
|
||||
server={props.server}
|
||||
record={props.record}
|
||||
isOpenTab={props.isOpenTab}
|
||||
render={(state) => (
|
||||
<HomeSessionLeading
|
||||
record={props.record}
|
||||
revealProjectOnHover={props.revealProjectOnHover}
|
||||
open={state.open()}
|
||||
unread={state.unread()}
|
||||
loading={state.loading()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionLeading(props: {
|
||||
record: HomeSessionRecord
|
||||
revealProjectOnHover: boolean
|
||||
open: boolean
|
||||
unread: boolean
|
||||
loading: boolean
|
||||
}) {
|
||||
return (
|
||||
<div class="relative shrink-0">
|
||||
<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"
|
||||
style={{ right: "calc(100% + 4px)" }}
|
||||
/>
|
||||
</Show>
|
||||
<SessionTabAvatarView
|
||||
project={props.record.project}
|
||||
directory={props.record.session.directory}
|
||||
revealProjectOnHover={props.revealProjectOnHover}
|
||||
unread={props.unread}
|
||||
loading={props.loading}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
return (
|
||||
<div class="w-full">
|
||||
<div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
|
||||
<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)]"
|
||||
style={{ top: "-6px", left: "-6px", width: "calc(100% + 12px)" }}
|
||||
>
|
||||
<div class="flex flex-col pt-9">
|
||||
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
|
||||
<Show
|
||||
when={!props.searchLoading()}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
|
||||
<Spinner class="size-4" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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]">
|
||||
{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]">
|
||||
{props.language.t("home.sessions.search.sessions")}
|
||||
</p>
|
||||
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
|
||||
<div class="flex flex-col gap-px pb-2">
|
||||
<For each={props.searchResults()}>
|
||||
{(record) => (
|
||||
<HomeSessionSearchResultRow
|
||||
{...props}
|
||||
record={record}
|
||||
selected={props.searchActive() === homeSessionSearchKey(record)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<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 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()}
|
||||
aria-expanded={props.searchOpen()}
|
||||
aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={
|
||||
props.searchActive() && props.searchOpen()
|
||||
? `home-session-search-option-${props.searchActive()}`
|
||||
: undefined
|
||||
}
|
||||
onFocus={props.onSearchFocus}
|
||||
onInput={(event) => props.onSearchInput(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
props.onSearchClose()
|
||||
event.currentTarget.blur()
|
||||
return
|
||||
}
|
||||
if (!props.searchOpen() || props.searchResults().length === 0) return
|
||||
if (event.altKey || event.metaKey) return
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
props.onSearchMove(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
props.onSearchMove(-1)
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
props.onSearchSelectActive()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Show when={props.searchValue()}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="relative z-20 shrink-0"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
aria-label={props.searchPlaceholder()}
|
||||
onClick={() => {
|
||||
props.onSearchClose()
|
||||
props.onSearchFocus()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionSearchResultRow(
|
||||
props: HomeSessionsViewProps & {
|
||||
record: HomeSessionRecord
|
||||
selected: boolean
|
||||
},
|
||||
) {
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
const key = () => homeSessionSearchKey(props.record)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
id={`home-session-search-option-${key()}`}
|
||||
data-key={key()}
|
||||
data-component="home-session-search-row"
|
||||
role="option"
|
||||
aria-selected={props.selected}
|
||||
classList={{
|
||||
[HOME_SEARCH_RESULT_ROW]: true,
|
||||
"bg-v2-overlay-simple-overlay-hover": props.selected,
|
||||
group: !!showProjectName(),
|
||||
}}
|
||||
onMouseEnter={() => props.onSearchHighlight(props.record)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
}}
|
||||
onClick={(event) => props.onSearchSelect(props.record, { background: isBackgroundOpen(event) })}
|
||||
onAuxClick={(event) => {
|
||||
if (!isBackgroundOpen(event)) return
|
||||
event.preventDefault()
|
||||
props.onSearchSelect(props.record, { background: true })
|
||||
}}
|
||||
>
|
||||
<HomeSessionLeadingController
|
||||
server={props.server}
|
||||
isOpenTab={props.isOpenTab}
|
||||
record={props.record}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<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()}>
|
||||
<span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionGroupHeader(props: {
|
||||
title: string
|
||||
titleOpacity: number
|
||||
onSetRef: (element: HTMLDivElement) => void
|
||||
elevated?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
ref={props.onSetRef}
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
|
||||
return (
|
||||
<div
|
||||
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
|
||||
classList={{ group: !!showProjectName() }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
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()
|
||||
}}
|
||||
onClick={(event) => props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })}
|
||||
onAuxClick={(event) => {
|
||||
if (!isBackgroundOpen(event)) return
|
||||
event.preventDefault()
|
||||
props.onOpenSession(props.record.session, { background: true })
|
||||
}}
|
||||
>
|
||||
<HomeSessionLeadingController
|
||||
server={props.server}
|
||||
isOpenTab={props.isOpenTab}
|
||||
record={props.record}
|
||||
revealProjectOnHover={!!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()}>
|
||||
<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">
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
|
||||
<IconButtonV2
|
||||
data-action="home-session-archive"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="archive" />}
|
||||
aria-label={props.language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.onArchiveSession(props.record.session)
|
||||
}}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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]">
|
||||
{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]">
|
||||
{props.language.t("home.sessions.empty.description")}
|
||||
</p>
|
||||
<Show when={props.onNewSession}>
|
||||
{(onNewSession) => (
|
||||
<ButtonV2 data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
|
||||
{props.language.t("command.session.new")}
|
||||
</ButtonV2>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionSkeleton(props: { label: string }) {
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<div class="flex h-7 min-w-0 items-center justify-between px-4">
|
||||
<div class={HOME_SECTION_LABEL}>{props.label}</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
|
||||
<For each={[0, 1, 2, 3]}>{() => <div class="h-10 rounded-[6px] bg-v2-background-bg-deep opacity-70" />}</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type ServerConnection, useServer } from "@/context/server"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { DateTime } from "luxon"
|
||||
import { createMemo, For, Match, Switch } from "solid-js"
|
||||
|
||||
export function LegacyHome() {
|
||||
const sync = useServerSync()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const homedir = createMemo(() => sync().data.path.home)
|
||||
const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
|
||||
const recent = createMemo(() => {
|
||||
return sync()
|
||||
.data.project.slice()
|
||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
const serverDotClass = createMemo(() => {
|
||||
const healthy = global.servers.health[server.key]?.healthy
|
||||
if (healthy === true) return "bg-icon-success-base"
|
||||
if (healthy === false) return "bg-icon-critical-base"
|
||||
return "bg-border-weak-base"
|
||||
})
|
||||
|
||||
function openProject(conn: ServerConnection.Any, directory: string) {
|
||||
const serverCtx = global.ensureServerCtx(conn)
|
||||
serverCtx.projects.open(directory)
|
||||
serverCtx.projects.touch(directory)
|
||||
navigate(`/${base64Encode(directory)}`)
|
||||
}
|
||||
|
||||
function chooseProject() {
|
||||
if (serverUnreachable()) return
|
||||
const conn = server.current
|
||||
if (!conn) return
|
||||
|
||||
const resolve = (result: string | string[] | null) => {
|
||||
if (Array.isArray(result)) {
|
||||
result.forEach((directory) => openProject(conn, directory))
|
||||
return
|
||||
}
|
||||
if (result) openProject(conn, result)
|
||||
}
|
||||
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="mx-auto mt-55 w-full md:w-auto px-4">
|
||||
<Logo class="md:w-xl opacity-12" />
|
||||
<Button
|
||||
size="large"
|
||||
variant="ghost"
|
||||
class="mt-4 mx-auto text-14-regular text-text-weak"
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-2 rounded-full": true,
|
||||
[serverDotClass()]: true,
|
||||
}}
|
||||
/>
|
||||
{server.name}
|
||||
</Button>
|
||||
<Switch>
|
||||
<Match when={sync().data.project.length > 0}>
|
||||
<div class="mt-20 w-full flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center justify-between pl-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
|
||||
<Button
|
||||
icon="folder-add-left"
|
||||
size="normal"
|
||||
class="pl-2 pr-3"
|
||||
disabled={serverUnreachable()}
|
||||
onClick={chooseProject}
|
||||
>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-2">
|
||||
<For each={recent()}>
|
||||
{(project) => (
|
||||
<Button
|
||||
size="large"
|
||||
variant="ghost"
|
||||
class="text-14-mono text-left justify-between px-3"
|
||||
onClick={() => openProject(server.current!, project.worktree)}
|
||||
>
|
||||
{project.worktree.replace(homedir(), "~")}
|
||||
<div class="text-14-regular text-text-weak">
|
||||
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={!sync().ready}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
||||
<Button class="px-3" disabled={serverUnreachable()} onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<Icon name="folder-add-left" size="large" />
|
||||
<div class="flex flex-col gap-1 items-center justify-center">
|
||||
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
|
||||
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
|
||||
</div>
|
||||
<Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -36,6 +36,7 @@ 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"
|
||||
@@ -48,6 +49,7 @@ 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"
|
||||
@@ -875,11 +877,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
const index = sessions.findIndex((s) => s.id === session.id)
|
||||
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
||||
|
||||
await serverSDK().client.session.update({
|
||||
directory: session.directory,
|
||||
sessionID: session.id,
|
||||
time: { archived: Date.now() },
|
||||
})
|
||||
await serverSDK().api.session.archive({ sessionID: session.id, directory: session.directory })
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, session.id, (s) => s.id)
|
||||
@@ -1185,9 +1183,12 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
}
|
||||
const refreshDirs = async (target?: string) => {
|
||||
if (!target || target === root || canOpen(target)) return canOpen(target)
|
||||
const listed = await serverSDK()
|
||||
.client.worktree.list({ directory: root })
|
||||
.then((x) => x.data ?? [])
|
||||
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)))
|
||||
.catch(() => [] as string[])
|
||||
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
|
||||
return canOpen(target)
|
||||
@@ -1231,10 +1232,11 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
await Promise.all(
|
||||
dirs.map(async (item) => ({
|
||||
path: { directory: item },
|
||||
session: await serverSDK()
|
||||
.client.session.list({ directory: item })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => []),
|
||||
session: await listAllSessions(serverSDK().api.session, {
|
||||
directory: item,
|
||||
parentID: null,
|
||||
order: "desc",
|
||||
}).catch(() => []),
|
||||
})),
|
||||
),
|
||||
Date.now(),
|
||||
@@ -1294,7 +1296,10 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
const name = next === getFilename(project.worktree) ? "" : next
|
||||
|
||||
if (project.id && project.id !== "global") {
|
||||
await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name })
|
||||
const result = await serverSDK().api.project.update({ projectID: project.id, name })
|
||||
serverSync().set("project", (items) =>
|
||||
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1445,10 +1450,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
})
|
||||
const dismiss = () => toaster.dismiss(progress)
|
||||
|
||||
const sessions: Session[] = await serverSDK()
|
||||
.client.session.list({ directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
|
||||
|
||||
clearWorkspaceTerminals(
|
||||
directory,
|
||||
@@ -1477,17 +1479,12 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
return
|
||||
}
|
||||
|
||||
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 },
|
||||
})
|
||||
.api.session.archive({ sessionID: session.id, directory: session.directory })
|
||||
.catch(() => undefined),
|
||||
),
|
||||
)
|
||||
@@ -1524,9 +1521,9 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
|
||||
onMount(() => {
|
||||
serverSDK()
|
||||
.client.vcs.status({ directory: props.directory })
|
||||
.then((x) => {
|
||||
const files = x.data ?? []
|
||||
.api.vcs.status({ location: { directory: props.directory } })
|
||||
.then((result) => {
|
||||
const files = result.data
|
||||
const dirty = files.length > 0
|
||||
setData({ status: "ready", dirty })
|
||||
})
|
||||
@@ -1582,19 +1579,19 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
const sessions = await serverSDK()
|
||||
.client.session.list({ directory: props.directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
const sessions = await listAllSessions(serverSDK().api.session, {
|
||||
directory: props.directory,
|
||||
order: "desc",
|
||||
}).catch(() => [])
|
||||
const active = sessions.filter((session) => session.time.archived === undefined)
|
||||
setState({ sessions: active })
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
serverSDK()
|
||||
.client.vcs.status({ directory: props.directory })
|
||||
.then((x) => {
|
||||
const files = x.data ?? []
|
||||
.api.vcs.status({ location: { directory: props.directory } })
|
||||
.then((result) => {
|
||||
const files = result.data
|
||||
const dirty = files.length > 0
|
||||
setState({ status: "ready", dirty })
|
||||
void refresh()
|
||||
|
||||
@@ -19,34 +19,16 @@ export function SessionTabAvatar(props: {
|
||||
() => props.directory,
|
||||
() => props.sessionId,
|
||||
)
|
||||
return (
|
||||
<SessionTabAvatarView
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
revealProjectOnHover={props.revealProjectOnHover}
|
||||
unread={state.unread()}
|
||||
loading={state.loading()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionTabAvatarView(props: {
|
||||
project?: LocalProject
|
||||
directory: string
|
||||
revealProjectOnHover?: boolean
|
||||
unread: boolean
|
||||
loading: boolean
|
||||
}) {
|
||||
const projectAvatar = () => (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
unread={props.unread}
|
||||
unread={state.unread()}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<Show when={props.loading} fallback={projectAvatar()}>
|
||||
<Show when={state.loading()} fallback={projectAvatar()}>
|
||||
<span class="relative block size-4 shrink-0">
|
||||
<SessionProgressIndicatorV2
|
||||
class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`}
|
||||
|
||||
@@ -45,7 +45,8 @@ export function createPromptInputController(input: {
|
||||
model: {
|
||||
selection: input.model ?? local.model,
|
||||
paid: providers.paid().length > 0,
|
||||
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||
loading:
|
||||
(local.agent.visible() && agentsQuery.isLoading) || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||
},
|
||||
session: {
|
||||
id: input.sessionID(),
|
||||
|
||||
@@ -82,7 +82,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
|
||||
setStore("responding", perm.id)
|
||||
sdk()
|
||||
.client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
|
||||
.api.permission.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response })
|
||||
.catch((err: unknown) => {
|
||||
const description = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description })
|
||||
|
||||
@@ -223,7 +223,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
}
|
||||
|
||||
const replyMutation = useMutation(() => ({
|
||||
mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
|
||||
mutationFn: (answers: QuestionAnswer[]) =>
|
||||
sdk().api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
@@ -235,7 +236,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
}))
|
||||
|
||||
const rejectMutation = useMutation(() => ({
|
||||
mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
|
||||
mutationFn: () => sdk().api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
|
||||
@@ -235,7 +235,7 @@ export function SessionFileView(props: SessionFileViewProps) {
|
||||
const source = props.diff
|
||||
if (!source) return
|
||||
const loaded = loadedDiff()
|
||||
return normalize(loaded?.source === source && loaded.version === props.diffVersion ? loaded.value : source)
|
||||
return normalize(loaded && loaded.source === source && loaded.version === props.diffVersion ? loaded.value : source)
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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,
|
||||
@@ -14,7 +15,7 @@ import type { LineComment } from "@/context/comments"
|
||||
|
||||
export type DiffStyle = "unified" | "split"
|
||||
|
||||
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
|
||||
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
|
||||
|
||||
export interface SessionReviewTabProps {
|
||||
title?: JSX.Element
|
||||
|
||||
@@ -24,6 +24,7 @@ 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"
|
||||
|
||||
@@ -56,15 +57,16 @@ 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 RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
|
||||
type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
function renderDiff(value: ReviewDiff): value is RenderDiff {
|
||||
return typeof value.file === "string"
|
||||
}
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
|
||||
diffs: () => ReviewDiff[]
|
||||
diffsReady: () => boolean
|
||||
empty: () => string
|
||||
hasReview: () => boolean
|
||||
|
||||
@@ -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 { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
@@ -283,6 +283,14 @@ 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
|
||||
@@ -291,8 +299,7 @@ export function MessageTimeline(props: {
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||
const shareUrl = createMemo(() => info()?.share?.url)
|
||||
const protocol = useServerProtocol()
|
||||
const shareEnabled = createMemo(() => protocol() === "v1" && sync().data.config.share !== "disabled")
|
||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const parent = createMemo(() => {
|
||||
const id = parentID()
|
||||
@@ -325,7 +332,7 @@ export function MessageTimeline(props: {
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||
const projection = createTimelineProjection({
|
||||
messages: sessionMessages,
|
||||
userMessages: () => props.userMessages,
|
||||
sessionMessages: projectedMessages,
|
||||
parts: getMsgParts,
|
||||
status: sessionStatus,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
@@ -665,8 +672,7 @@ export function MessageTimeline(props: {
|
||||
}))
|
||||
|
||||
const titleMutation = useMutation(() => ({
|
||||
mutationFn: (input: { id: string; title: string }) =>
|
||||
sdk().client.session.update({ sessionID: input.id, title: input.title }),
|
||||
mutationFn: (input: { id: string; title: string }) => sdk().api.session.rename({ sessionID: input.id, title: input.title }),
|
||||
onSuccess: (_, input) => {
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
@@ -810,7 +816,7 @@ export function MessageTimeline(props: {
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
await sdk()
|
||||
.client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
.api.session.archive({ sessionID })
|
||||
.then(() => {
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
@@ -839,8 +845,8 @@ export function MessageTimeline(props: {
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
const result = await sdk()
|
||||
.client.session.delete({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.api.session.remove({ sessionID })
|
||||
.then(() => true)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("session.delete.failed.title"),
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { createMemo, mapArray, type Accessor } from "solid-js"
|
||||
import { createMemo, 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>
|
||||
@@ -30,47 +28,19 @@ export function createTimelineProjection(input: {
|
||||
})
|
||||
return result
|
||||
})
|
||||
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 projection = createMemo(() =>
|
||||
Timeline.constructSessionMessageRows(
|
||||
input.sessionMessages(),
|
||||
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
|
||||
input.parts,
|
||||
input.showReasoningSummaries(),
|
||||
input.status().type,
|
||||
input.inlineComments(),
|
||||
),
|
||||
)
|
||||
const activeMessageID = createMemo(() => projection().activeMessageID)
|
||||
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||
reuseTimelineRows(
|
||||
previous,
|
||||
messageRowMemos().flatMap((memo) => memo()),
|
||||
),
|
||||
reuseTimelineRows(previous, projection().rows),
|
||||
)
|
||||
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
|
||||
const messageRowIndex = createMemo(() => {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_shell")
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_shell",
|
||||
"assistant-part:msg_shell:msg_shell:tool",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
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"
|
||||
@@ -31,6 +32,47 @@ 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,
|
||||
) {
|
||||
const turns = messages.reduce<{ user: UserMessage; assistants: AssistantMessage[] }[]>((result, message) => {
|
||||
const projected = getMessage(message.id)
|
||||
if (message.type === "shell" && projected?.role === "user") {
|
||||
const assistant = getMessage(`${message.id}:assistant`)
|
||||
result.push({ user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] })
|
||||
return result
|
||||
}
|
||||
if (projected?.role === "user") {
|
||||
result.push({ user: projected, assistants: [] })
|
||||
return result
|
||||
}
|
||||
if (projected?.role !== "assistant") return result
|
||||
result.at(-1)?.assistants.push(projected)
|
||||
return result
|
||||
}, [])
|
||||
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,7 +5,6 @@ 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"
|
||||
@@ -19,7 +18,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 { useServerProtocol } from "@/context/server-sdk"
|
||||
import { useLocal } from "@/context/local"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
@@ -41,15 +40,14 @@ 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)
|
||||
@@ -186,7 +184,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
|
||||
const share = async () => {
|
||||
if (protocol() !== "v1") return
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
@@ -213,7 +210,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
|
||||
const unshare = async () => {
|
||||
if (protocol() !== "v1") return
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
@@ -310,7 +306,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const owner = sessionOwnership.capture()
|
||||
const client = sdk().client
|
||||
const session = sdk().api.session
|
||||
const directory = sdk().directory
|
||||
const promptSession = prompt.capture()
|
||||
const revert = info()?.revert?.messageID
|
||||
@@ -320,13 +316,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const parts = sync().data.part[message.id]
|
||||
|
||||
if (sync().data.session_working(sessionID)) {
|
||||
await client.session.abort({ sessionID }).catch(() => {})
|
||||
await session.interrupt({ sessionID }).catch(() => {})
|
||||
}
|
||||
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => client.session.revert({ sessionID, messageID: message.id }),
|
||||
request: () => session.revert.stage({ sessionID, messageID: message.id }),
|
||||
updatePrompt: (promptSession) => {
|
||||
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
|
||||
},
|
||||
@@ -338,7 +334,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const owner = sessionOwnership.capture()
|
||||
const client = sdk().client
|
||||
const session = sdk().api.session
|
||||
const messages = userMessages()
|
||||
const promptSession = prompt.capture()
|
||||
|
||||
@@ -350,7 +346,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => client.session.unrevert({ sessionID }),
|
||||
request: () => session.revert.clear({ sessionID }),
|
||||
updatePrompt: (promptSession) => promptSession.reset(),
|
||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
|
||||
})
|
||||
@@ -360,7 +356,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => client.session.revert({ sessionID, messageID: next.id }),
|
||||
request: () => session.revert.stage({ sessionID, messageID: next.id }),
|
||||
updatePrompt: () => undefined,
|
||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
|
||||
})
|
||||
@@ -379,10 +375,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
return
|
||||
}
|
||||
|
||||
await sdk().client.session.summarize({
|
||||
await sdk().api.session.compact({
|
||||
sessionID,
|
||||
modelID: model.id,
|
||||
providerID: model.provider.id,
|
||||
model: { providerID: model.provider.id, modelID: model.id },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -394,7 +389,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
|
||||
const shareCmds = () => {
|
||||
if (protocol() !== "v1") return []
|
||||
if (sync().data.config.share === "disabled") return []
|
||||
return [
|
||||
sessionCommand({
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
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 = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
export type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
export function normalizePath(p: string) {
|
||||
return normalizeFileTreeV2Path(p)
|
||||
}
|
||||
|
||||
export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
export function filterRenderableDiff(value: FileDiffInfo | SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
return typeof value.file === "string"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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,
|
||||
@@ -30,7 +31,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 = SnapshotFileDiff | VcsFileDiff
|
||||
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
|
||||
|
||||
export type ReviewPanelV2Props = {
|
||||
title?: JSX.Element
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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"
|
||||
|
||||
@@ -9,7 +10,7 @@ const item = {
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
} satisfies SnapshotFileDiff
|
||||
} satisfies FileDiffInfo & SnapshotFileDiff
|
||||
|
||||
describe("diffs", () => {
|
||||
test("keeps valid arrays", () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 = SnapshotFileDiff | VcsFileDiff
|
||||
type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
|
||||
|
||||
function diff(value: unknown): value is Diff {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
||||
|
||||
@@ -2,10 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { createApiForServer, createSdkForServer } from "./server"
|
||||
import { createCompatibleApi } from "./server-compat"
|
||||
|
||||
function setup(
|
||||
protocol: "v1" | "v2" | Promise<"v1" | "v2">,
|
||||
responses?: { vcs?: { branch: string; default_branch: string } },
|
||||
) {
|
||||
function setup(protocol: "v1" | "v2") {
|
||||
const requests: Request[] = []
|
||||
const fetcher = Object.assign(
|
||||
async (input: string | URL | Request, init?: RequestInit) => {
|
||||
@@ -24,7 +21,7 @@ function setup(
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/prompt_async"))
|
||||
return new Response(undefined, { status: 204 })
|
||||
if (request.method === "POST" && request.url.endsWith("/prompt")) {
|
||||
if (request.method === "POST" && request.url.endsWith("/prompt")) {
|
||||
return Response.json({
|
||||
admittedSeq: 1,
|
||||
id: "msg_1",
|
||||
@@ -33,18 +30,16 @@ function setup(
|
||||
type: "user",
|
||||
data: { text: "hello" },
|
||||
delivery: "steer",
|
||||
})
|
||||
}
|
||||
if (request.method === "GET" && new URL(request.url).pathname === "/vcs")
|
||||
return Response.json(responses?.vcs ?? {})
|
||||
if (request.method === "GET") return Response.json([])
|
||||
return new Response(undefined, { status: 204 })
|
||||
})
|
||||
}
|
||||
if (request.method === "GET") return Response.json([])
|
||||
return new Response(undefined, { status: 204 })
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
)
|
||||
const server = { url: "http://localhost:4096" }
|
||||
const api = createCompatibleApi({
|
||||
protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol,
|
||||
protocol: Promise.resolve(protocol),
|
||||
current: createApiForServer({ server, fetch: fetcher }),
|
||||
legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }),
|
||||
directory: "/repo",
|
||||
@@ -91,36 +86,10 @@ describe("createCompatibleApi", () => {
|
||||
expect(requests[0]!.method).toBe("POST")
|
||||
})
|
||||
|
||||
test("resolves protocol detection once across implementation methods", async () => {
|
||||
let detections = 0
|
||||
const resolved = Promise.resolve<"v1" | "v2">("v2")
|
||||
const protocol = new Proxy(resolved, {
|
||||
get(target, property) {
|
||||
if (property !== "then") return Reflect.get(target, property, target)
|
||||
detections++
|
||||
return target.then.bind(target)
|
||||
},
|
||||
})
|
||||
const { api } = setup(protocol)
|
||||
|
||||
await api.session.archive({ sessionID: "ses_1" })
|
||||
await api.session.list()
|
||||
|
||||
expect(detections).toBe(1)
|
||||
})
|
||||
|
||||
test("uses the global V1 session search endpoint", async () => {
|
||||
const { api, requests } = setup("v1")
|
||||
await api.session.list({ parentID: null, search: "session", limit: 50 })
|
||||
|
||||
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
|
||||
})
|
||||
|
||||
test("projects the V1 default branch", async () => {
|
||||
const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } })
|
||||
|
||||
expect(await api.vcs.get({ location: { directory: "/repo" } })).toMatchObject({
|
||||
data: { branch: "feature", defaultBranch: "dev" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,12 +37,6 @@ type LegacyPrompt = {
|
||||
variant?: string
|
||||
}
|
||||
type LegacyLocation = { directory?: string }
|
||||
type CompatibleInput = {
|
||||
protocol: Promise<ServerProtocol>
|
||||
current: ServerApi
|
||||
legacy: LegacyFor
|
||||
directory?: string
|
||||
}
|
||||
|
||||
function mime(uri: string) {
|
||||
const match = /^data:([^;,]+)/.exec(uri)
|
||||
@@ -74,48 +68,15 @@ function sessionInfo(session: Session): SessionInfo {
|
||||
}
|
||||
}
|
||||
|
||||
export function createCompatibleApi(input: CompatibleInput): CompatibleApi {
|
||||
const v1 = createV1Api(input)
|
||||
return lazyApi(
|
||||
input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)),
|
||||
input.current,
|
||||
)
|
||||
}
|
||||
|
||||
function lazyApi<T extends object>(implementation: Promise<T>, shape: T): T {
|
||||
const cache = new Map<PropertyKey, unknown>()
|
||||
return new Proxy(shape, {
|
||||
get(target, property, receiver) {
|
||||
const sample = Reflect.get(target, property, receiver)
|
||||
if (typeof sample === "function") {
|
||||
return (...args: unknown[]) =>
|
||||
implementation.then((value) => {
|
||||
const method = Reflect.get(value, property)
|
||||
if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`)
|
||||
return Reflect.apply(method, value, args)
|
||||
})
|
||||
}
|
||||
if (sample === null || typeof sample !== "object") return sample
|
||||
if (cache.has(property)) return cache.get(property)
|
||||
const nested = lazyApi(
|
||||
implementation.then((value) => {
|
||||
const result = Reflect.get(value, property)
|
||||
if (result === null || typeof result !== "object") {
|
||||
throw new Error(`API namespace unavailable: ${String(property)}`)
|
||||
}
|
||||
return result
|
||||
}),
|
||||
sample,
|
||||
)
|
||||
cache.set(property, nested)
|
||||
return nested
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
export function createCompatibleApi(input: {
|
||||
protocol: Promise<ServerProtocol>
|
||||
current: ServerApi
|
||||
legacy: LegacyFor
|
||||
directory?: string
|
||||
}): CompatibleApi {
|
||||
const directory = (location?: { directory?: string }) => location?.directory ?? input.directory
|
||||
const legacy = (location?: { directory?: string }) => input.legacy(directory(location))
|
||||
const isV1 = () => input.protocol.then((protocol) => protocol === "v1")
|
||||
const located = <T>(data: T, value?: { directory?: string }) => ({
|
||||
location: {
|
||||
directory: directory(value) ?? "",
|
||||
@@ -132,6 +93,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
value?: Parameters<ServerApi["session"]["list"]>[0],
|
||||
options?: Parameters<ServerApi["session"]["list"]>[1],
|
||||
) {
|
||||
if (!(await isV1())) return input.current.session.list(value, options)
|
||||
if (!value?.directory && value?.search !== undefined) {
|
||||
const result = await legacy().experimental.session.list(
|
||||
{
|
||||
@@ -152,6 +114,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
|
||||
},
|
||||
async create(value?: Parameters<ServerApi["session"]["create"]>[0]) {
|
||||
if (!(await isV1())) return input.current.session.create(value)
|
||||
const result = await legacy(value?.location ?? undefined).session.create({
|
||||
directory: directory(value?.location ?? undefined),
|
||||
})
|
||||
@@ -159,11 +122,13 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
return sessionInfo(result.data)
|
||||
},
|
||||
async get(value: Parameters<ServerApi["session"]["get"]>[0]) {
|
||||
if (!(await isV1())) return input.current.session.get(value)
|
||||
const result = await legacy().session.get(value)
|
||||
if (!result.data) throw new Error(`Session not found: ${value.sessionID}`)
|
||||
return sessionInfo(result.data)
|
||||
},
|
||||
async active() {
|
||||
if (!(await isV1())) return input.current.session.active()
|
||||
const result = await legacy().session.status()
|
||||
return Object.fromEntries(
|
||||
Object.entries(result.data ?? {}).flatMap(([sessionID, status]) =>
|
||||
@@ -172,23 +137,29 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
)
|
||||
},
|
||||
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
|
||||
if (!(await isV1())) return input.current.session.rename(value)
|
||||
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
|
||||
},
|
||||
async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
|
||||
if (!(await isV1())) return input.current.session.archive(value)
|
||||
await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
|
||||
},
|
||||
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
|
||||
if (!(await isV1())) return input.current.session.remove(value)
|
||||
await legacy(value).session.delete(value)
|
||||
},
|
||||
async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) {
|
||||
if (!(await isV1())) return input.current.session.fork(value)
|
||||
const result = await legacy().session.fork(value)
|
||||
if (!result.data) throw new Error("Failed to fork session")
|
||||
return sessionInfo(result.data)
|
||||
},
|
||||
async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
|
||||
if (!(await isV1())) return input.current.session.interrupt(value)
|
||||
await legacy().session.abort(value)
|
||||
},
|
||||
async prompt(value: SessionPromptInput & LegacyPrompt) {
|
||||
if (!(await isV1())) return input.current.session.prompt(value)
|
||||
await legacy().session.promptAsync({
|
||||
sessionID: value.sessionID,
|
||||
messageID: value.id ?? undefined,
|
||||
@@ -223,6 +194,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
}
|
||||
},
|
||||
async command(value: SessionCommandInput) {
|
||||
if (!(await isV1())) return input.current.session.command(value)
|
||||
await legacy().session.command({
|
||||
sessionID: value.sessionID,
|
||||
messageID: value.id ?? undefined,
|
||||
@@ -249,6 +221,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
}
|
||||
},
|
||||
async shell(value: SessionShellInput & LegacyPrompt) {
|
||||
if (!(await isV1())) return input.current.session.shell(value)
|
||||
await legacy().session.shell({
|
||||
sessionID: value.sessionID,
|
||||
command: value.command,
|
||||
@@ -257,6 +230,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
})
|
||||
},
|
||||
compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => {
|
||||
if (!(await isV1())) return input.current.session.compact(value)
|
||||
if (!value.model) throw new Error("A model is required to compact a V1 session")
|
||||
await legacy().session.summarize({
|
||||
sessionID: value.sessionID,
|
||||
@@ -273,10 +247,12 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
},
|
||||
revert: {
|
||||
stage: async (value: Parameters<ServerApi["session"]["revert"]["stage"]>[0]) => {
|
||||
if (!(await isV1())) return input.current.session.revert.stage(value)
|
||||
await legacy().session.revert(value)
|
||||
return { messageID: value.messageID }
|
||||
},
|
||||
clear: async (value: Parameters<ServerApi["session"]["revert"]["clear"]>[0]) => {
|
||||
if (!(await isV1())) return input.current.session.revert.clear(value)
|
||||
await legacy().session.unrevert(value)
|
||||
},
|
||||
commit: input.current.session.revert.commit,
|
||||
@@ -285,14 +261,17 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
project: {
|
||||
...input.current.project,
|
||||
async list() {
|
||||
if (!(await isV1())) return input.current.project.list()
|
||||
return ((await legacy().project.list()).data ?? []) as Project[]
|
||||
},
|
||||
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
|
||||
if (!(await isV1())) return input.current.project.current(value)
|
||||
const result = await legacy(value?.location).project.current()
|
||||
if (!result.data) throw new Error("Project not found")
|
||||
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
|
||||
},
|
||||
async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
|
||||
if (!(await isV1())) return input.current.project.update(value)
|
||||
const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
|
||||
const result = await legacy({ directory: project?.worktree }).project.update({
|
||||
...value,
|
||||
@@ -302,6 +281,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
return result.data as Project
|
||||
},
|
||||
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
|
||||
if (!(await isV1())) return input.current.project.directories(value)
|
||||
const result = await legacy(value.location).worktree.list()
|
||||
return (result.data ?? []).map((item) => ({ directory: item }))
|
||||
},
|
||||
@@ -309,6 +289,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
path: {
|
||||
...input.current.path,
|
||||
async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
|
||||
if (!(await isV1())) return input.current.path.get(value)
|
||||
const result = await legacy(value?.location).path.get()
|
||||
if (!result.data) throw new Error("Path unavailable")
|
||||
return result.data
|
||||
@@ -317,14 +298,17 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
vcs: {
|
||||
...input.current.vcs,
|
||||
async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
|
||||
if (!(await isV1())) return input.current.vcs.get(value)
|
||||
const result = await legacy(value?.location).vcs.get()
|
||||
return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
|
||||
return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location)
|
||||
},
|
||||
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
|
||||
if (!(await isV1())) return input.current.vcs.status(value)
|
||||
const result = await legacy(value?.location).vcs.status()
|
||||
return located(result.data ?? [], value?.location)
|
||||
},
|
||||
async diff(value: Parameters<ServerApi["vcs"]["diff"]>[0]) {
|
||||
if (!(await isV1())) return input.current.vcs.diff(value)
|
||||
const result = await legacy(value.location).vcs.diff({
|
||||
mode: value.mode === "working" ? "git" : value.mode,
|
||||
context: value.context,
|
||||
@@ -344,10 +328,12 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
file: {
|
||||
...input.current.file,
|
||||
async list(value?: Parameters<ServerApi["file"]["list"]>[0]) {
|
||||
if (!(await isV1())) return input.current.file.list(value)
|
||||
const result = await legacy(value?.location).file.list({ path: value?.path ?? "" })
|
||||
return located(result.data ?? [], value?.location)
|
||||
},
|
||||
async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
|
||||
if (!(await isV1())) return input.current.file.find(value)
|
||||
const result = await legacy(value.location).find.files({
|
||||
query: value.query,
|
||||
type: value.type,
|
||||
@@ -362,6 +348,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
integration: {
|
||||
...input.current.integration,
|
||||
async get(value: Parameters<ServerApi["integration"]["get"]>[0]) {
|
||||
if (!(await isV1())) return input.current.integration.get(value)
|
||||
const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map(
|
||||
(method, index) =>
|
||||
method.type === "api"
|
||||
@@ -381,6 +368,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
connect: {
|
||||
...input.current.integration.connect,
|
||||
key: async (value: Parameters<ServerApi["integration"]["connect"]["key"]>[0]) => {
|
||||
if (!(await isV1())) return input.current.integration.connect.key(value)
|
||||
await legacy(value.location).auth.set({
|
||||
providerID: value.integrationID,
|
||||
auth: { type: "api", key: value.key },
|
||||
@@ -390,6 +378,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
oauth: {
|
||||
...input.current.integration.oauth,
|
||||
connect: async (value: Parameters<ServerApi["integration"]["oauth"]["connect"]>[0]) => {
|
||||
if (!(await isV1())) return input.current.integration.oauth.connect(value)
|
||||
const method = Number(value.methodID)
|
||||
const result = await legacy(value.location).provider.oauth.authorize(
|
||||
{ providerID: value.integrationID, method, inputs: value.inputs },
|
||||
@@ -408,6 +397,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
)
|
||||
},
|
||||
complete: async (value: Parameters<ServerApi["integration"]["oauth"]["complete"]>[0]) => {
|
||||
if (!(await isV1())) return input.current.integration.oauth.complete(value)
|
||||
const method = Number(value.attemptID.split(":").at(-1))
|
||||
await legacy(value.location).provider.oauth.callback(
|
||||
{ providerID: value.integrationID, method, code: value.code },
|
||||
@@ -415,6 +405,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
)
|
||||
},
|
||||
status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
|
||||
if (!(await isV1())) return input.current.integration.oauth.status(value)
|
||||
const method = Number(value.attemptID.split(":").at(-1))
|
||||
await legacy(value.location).provider.oauth.callback(
|
||||
{ providerID: value.integrationID, method },
|
||||
@@ -430,12 +421,15 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
pty: {
|
||||
...input.current.pty,
|
||||
async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
|
||||
if (!(await isV1())) return input.current.pty.shells(value)
|
||||
return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
|
||||
},
|
||||
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
|
||||
if (!(await isV1())) return input.current.pty.list(value)
|
||||
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
|
||||
},
|
||||
async create(value?: Parameters<ServerApi["pty"]["create"]>[0]) {
|
||||
if (!(await isV1())) return input.current.pty.create(value)
|
||||
const result = await legacy(value?.location).pty.create({
|
||||
command: value?.command,
|
||||
args: value?.args ? [...value.args] : undefined,
|
||||
@@ -447,11 +441,13 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
return located(result.data, value?.location)
|
||||
},
|
||||
async get(value: Parameters<ServerApi["pty"]["get"]>[0]) {
|
||||
if (!(await isV1())) return input.current.pty.get(value)
|
||||
const result = await legacy(value.location).pty.get({ ptyID: value.ptyID })
|
||||
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
|
||||
return located(result.data, value.location)
|
||||
},
|
||||
async update(value: Parameters<ServerApi["pty"]["update"]>[0]) {
|
||||
if (!(await isV1())) return input.current.pty.update(value)
|
||||
const result = await legacy(value.location).pty.update({
|
||||
ptyID: value.ptyID,
|
||||
title: value.title,
|
||||
@@ -461,9 +457,11 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
return located(result.data, value.location)
|
||||
},
|
||||
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
|
||||
if (!(await isV1())) return input.current.pty.remove(value)
|
||||
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
|
||||
},
|
||||
async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
|
||||
if (!(await isV1())) return input.current.pty.connectToken(value)
|
||||
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)
|
||||
@@ -472,6 +470,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
permission: {
|
||||
...input.current.permission,
|
||||
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) {
|
||||
if (!(await isV1())) return input.current.permission.reply(value)
|
||||
await legacy().permission.respond({
|
||||
sessionID: value.sessionID,
|
||||
permissionID: value.requestID,
|
||||
@@ -482,12 +481,14 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
question: {
|
||||
...input.current.question,
|
||||
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
|
||||
if (!(await isV1())) return input.current.question.reply(value)
|
||||
await legacy().question.reply({
|
||||
requestID: value.requestID,
|
||||
answers: value.answers.map((answer) => [...answer]),
|
||||
})
|
||||
},
|
||||
async reject(value: Parameters<ServerApi["question"]["reject"]>[0]) {
|
||||
if (!(await isV1())) return input.current.question.reject(value)
|
||||
await legacy().question.reject({ requestID: value.requestID })
|
||||
},
|
||||
},
|
||||
|
||||
@@ -42,19 +42,6 @@ describe("checkServerHealth", () => {
|
||||
expect(paths).toEqual(["/api/health", "/global/health"])
|
||||
})
|
||||
|
||||
test("falls back when the current health response is malformed", async () => {
|
||||
const paths: string[] = []
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
||||
paths.push(url.pathname)
|
||||
if (url.pathname === "/api/health") return Response.json({})
|
||||
return Response.json({ healthy: true, version: "1.18.4" })
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
|
||||
expect(paths).toEqual(["/api/health", "/global/health"])
|
||||
})
|
||||
|
||||
test("allows slow servers thirty seconds by default", async () => {
|
||||
const timeout = Object.getOwnPropertyDescriptor(AbortSignal, "timeout")
|
||||
let timeoutMs = 0
|
||||
|
||||
@@ -95,13 +95,9 @@ export async function checkServerHealth(
|
||||
: undefined,
|
||||
})
|
||||
.health.get({ signal })
|
||||
.then((x) =>
|
||||
typeof x.healthy === "boolean"
|
||||
? { data: { healthy: x.healthy, version: x.version } }
|
||||
: { error: new Error("Invalid health response") },
|
||||
)
|
||||
.then((x) => ({ data: { healthy: x.healthy === true, version: x.version } }))
|
||||
.catch((error) => ({ error }))
|
||||
if ("data" in current && current.data) return current.data
|
||||
if ("data" in current) return current.data
|
||||
if (signal?.aborted) return { healthy: false }
|
||||
|
||||
return createSdkForServer({ server, fetch, signal })
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
AgentApi,
|
||||
CatalogApi,
|
||||
CommandApi,
|
||||
EventApi,
|
||||
FileApi,
|
||||
IntegrationApi,
|
||||
McpApi,
|
||||
MessageApi,
|
||||
PathApi,
|
||||
PermissionApi,
|
||||
ProjectApi,
|
||||
PtyApi,
|
||||
QuestionApi,
|
||||
ReferenceApi,
|
||||
SessionApi,
|
||||
VcsApi,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
@@ -41,10 +59,28 @@ export function createSdkForServer({
|
||||
})
|
||||
}
|
||||
|
||||
export interface ServerApi extends CatalogApi {
|
||||
readonly agent: AgentApi
|
||||
readonly command: CommandApi
|
||||
readonly event: EventApi
|
||||
readonly file: FileApi
|
||||
readonly integration: IntegrationApi
|
||||
readonly mcp: McpApi
|
||||
readonly message: MessageApi
|
||||
readonly path: PathApi
|
||||
readonly permission: PermissionApi
|
||||
readonly project: ProjectApi
|
||||
readonly pty: PtyApi
|
||||
readonly question: QuestionApi
|
||||
readonly reference: ReferenceApi
|
||||
readonly session: SessionApi
|
||||
readonly vcs: VcsApi
|
||||
}
|
||||
|
||||
export function createApiForServer(input: {
|
||||
server: ServerConnection.HttpBase
|
||||
fetch?: typeof globalThis.fetch
|
||||
}): OpenCodeClient {
|
||||
}): ServerApi {
|
||||
return OpenCode.make({
|
||||
baseUrl: input.server.url,
|
||||
fetch: input.fetch,
|
||||
@@ -58,5 +94,3 @@ export function createApiForServer(input: {
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export type ServerApi = OpenCodeClient
|
||||
|
||||
@@ -122,7 +122,9 @@ describe("normalizeSessionMessages", () => {
|
||||
expect.objectContaining({ id: "msg_shell", role: "user" }),
|
||||
expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }),
|
||||
])
|
||||
expect(result.parts.get("msg_shell")).toEqual([expect.objectContaining({ type: "text", text: "printf hello" })])
|
||||
expect(result.parts.get("msg_shell")).toEqual([
|
||||
expect.objectContaining({ type: "text", text: "printf hello" }),
|
||||
])
|
||||
expect(result.parts.get("msg_shell:assistant")).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "tool",
|
||||
|
||||
@@ -2,51 +2,22 @@ import { describe, expect, test } from "bun:test"
|
||||
import { terminalWebSocketURL } from "./terminal-websocket-url"
|
||||
|
||||
describe("terminalWebSocketURL", () => {
|
||||
test("uses query auth without embedding credentials in websocket URL", () => {
|
||||
test("uses the current ticketed PTY route", () => {
|
||||
const url = terminalWebSocketURL({
|
||||
url: "http://127.0.0.1:49365",
|
||||
id: "pty_test",
|
||||
directory: "/tmp/project",
|
||||
cursor: 0,
|
||||
sameOrigin: false,
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
ticket: "connect-ticket",
|
||||
})
|
||||
|
||||
expect(url.protocol).toBe("ws:")
|
||||
expect(url.username).toBe("")
|
||||
expect(url.password).toBe("")
|
||||
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
|
||||
})
|
||||
|
||||
test("omits query auth for same-origin saved credentials", () => {
|
||||
const url = terminalWebSocketURL({
|
||||
url: "https://app.example.test",
|
||||
id: "pty_test",
|
||||
directory: "/tmp/project",
|
||||
cursor: 10,
|
||||
sameOrigin: true,
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
})
|
||||
|
||||
expect(url.protocol).toBe("wss:")
|
||||
expect(url.pathname).toBe("/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 for same-origin credentials from auth_token", () => {
|
||||
const url = terminalWebSocketURL({
|
||||
url: "https://app.example.test",
|
||||
id: "pty_test",
|
||||
directory: "/tmp/project",
|
||||
cursor: 10,
|
||||
sameOrigin: true,
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
authToken: true,
|
||||
})
|
||||
|
||||
expect(url.protocol).toBe("wss:")
|
||||
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
import { authTokenFromCredentials } from "@/utils/server"
|
||||
|
||||
export function terminalWebSocketURL(input: {
|
||||
url: string
|
||||
id: string
|
||||
directory: string
|
||||
cursor: number
|
||||
ticket?: string
|
||||
sameOrigin?: boolean
|
||||
username?: string
|
||||
password?: string
|
||||
authToken?: boolean
|
||||
ticket: string
|
||||
}) {
|
||||
const next = new URL(`${input.url}/pty/${input.id}/connect`)
|
||||
next.searchParams.set("directory", input.directory)
|
||||
const next = new URL(`${input.url}/api/pty/${input.id}/connect`)
|
||||
next.searchParams.set("location[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 (input.password && (!input.sameOrigin || input.authToken))
|
||||
next.searchParams.set(
|
||||
"auth_token",
|
||||
authTokenFromCredentials({ username: input.username, password: input.password }),
|
||||
)
|
||||
next.searchParams.set("ticket", input.ticket)
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createServerSessionEntries } from "@/components/command-palette"
|
||||
import type { LocalProject } from "@/context/layout"
|
||||
@@ -14,15 +15,16 @@ const stored: Project = {
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
|
||||
const session: GlobalSession = {
|
||||
const session: SessionInfo = {
|
||||
id: "session-1",
|
||||
slug: "session-1",
|
||||
projectID: stored.id,
|
||||
directory: stored.worktree,
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
location: { directory: stored.worktree },
|
||||
title: "Palette session",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 2 },
|
||||
project: { id: stored.id, name: stored.name, worktree: stored.worktree },
|
||||
}
|
||||
|
||||
describe("command palette sessions", () => {
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/mistral": "3.0.34",
|
||||
"@ai-sdk/openai": "3.0.84",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
|
||||
@@ -26,257 +26,3 @@ test("Mistral sends promptCacheKey as prompt_cache_key", async () => {
|
||||
|
||||
expect(body?.prompt_cache_key).toBe("session-123")
|
||||
})
|
||||
|
||||
test("Mistral round-trips native reasoning in assistant history", async () => {
|
||||
let body: { messages?: unknown[] } | undefined
|
||||
const mockFetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
body = JSON.parse(String(init?.body))
|
||||
return Response.json({
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
object: "chat.completion",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [
|
||||
{ type: "text", text: "The user is greeting me." },
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
{ type: "reference", reference_ids: [1, "source-2"] },
|
||||
],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
},
|
||||
{ type: "text", text: "Hi" },
|
||||
],
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
})
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest")
|
||||
|
||||
const first = await model.doGenerate({
|
||||
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
||||
})
|
||||
const reasoning = first.content.find((part) => part.type === "reasoning")
|
||||
const text = first.content.find((part) => part.type === "text")
|
||||
if (!reasoning || !text) throw new Error("expected reasoning and text")
|
||||
|
||||
await model.doGenerate({
|
||||
prompt: [
|
||||
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ ...reasoning, providerOptions: reasoning.providerMetadata }, text],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Hello again" }] },
|
||||
],
|
||||
})
|
||||
|
||||
expect(body?.messages?.[1]).toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [
|
||||
{ type: "text", text: "The user is greeting me." },
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
{ type: "reference", reference_ids: [1, "source-2"] },
|
||||
],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
},
|
||||
{ type: "text", text: "Hi" },
|
||||
],
|
||||
})
|
||||
|
||||
await model.doGenerate({
|
||||
prompt: [
|
||||
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{ type: "text", text: "Hi" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Hello again" }] },
|
||||
],
|
||||
})
|
||||
expect(body?.messages?.[1]).toEqual({ role: "assistant", content: "thinkingHi" })
|
||||
})
|
||||
|
||||
test("Mistral preserves native reasoning metadata while streaming", async () => {
|
||||
const chunks = [
|
||||
{
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [
|
||||
{ type: "text", text: "thinking" },
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [{ type: "reference", reference_ids: [1, "source-2"] }],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
choices: [{ index: 0, delta: { content: [{ type: "text", text: "answer" }] } }],
|
||||
},
|
||||
{
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
},
|
||||
]
|
||||
const mockFetch = Object.assign(
|
||||
async () =>
|
||||
new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join(""), {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}),
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest")
|
||||
const result = await model.doStream({
|
||||
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
||||
})
|
||||
const events = []
|
||||
for await (const event of result.stream) events.push(event)
|
||||
|
||||
expect(events.find((event) => event.type === "reasoning-end")?.providerMetadata).toEqual({
|
||||
mistral: {
|
||||
thinking: {
|
||||
type: "thinking",
|
||||
thinking: [
|
||||
{ type: "text", text: "thinking" },
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
{ type: "reference", reference_ids: [1, "source-2"] },
|
||||
],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(
|
||||
events
|
||||
.filter((event) => event.type === "reasoning-start" || event.type === "reasoning-delta")
|
||||
.every((event) => event.providerMetadata === undefined),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("Mistral preserves metadata-only thinking chunks", async () => {
|
||||
const thinking = {
|
||||
type: "thinking" as const,
|
||||
thinking: [
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
{ type: "reference", reference_ids: [1, "source-2"] },
|
||||
],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
}
|
||||
const mockFetch = Object.assign(
|
||||
async () =>
|
||||
Response.json({
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
object: "chat.completion",
|
||||
choices: [{ index: 0, message: { role: "assistant", content: [thinking] }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
}),
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest")
|
||||
const result = await model.doGenerate({
|
||||
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
||||
})
|
||||
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
providerMetadata: { mistral: { thinking } },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/mistral": "3.0.34",
|
||||
"@ai-sdk/openai": "3.0.84",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
|
||||
@@ -906,117 +906,6 @@ describe("session.llm.stream", () => {
|
||||
},
|
||||
)
|
||||
|
||||
const mistralFixture = { providerID: "mistral", modelID: "mistral-small-latest" }
|
||||
it.instance(
|
||||
"replays native Mistral reasoning from chat history",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = loadFixture(mistralFixture.providerID, mistralFixture.modelID)
|
||||
const request = waitRequest(
|
||||
"/chat/completions",
|
||||
createEventResponse(
|
||||
[
|
||||
{
|
||||
id: "chatcmpl-mistral",
|
||||
object: "chat.completion.chunk",
|
||||
created: 0,
|
||||
model: fixture.model.id,
|
||||
choices: [{ index: 0, delta: { role: "assistant", content: "Hello" } }],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-mistral",
|
||||
object: "chat.completion.chunk",
|
||||
created: 0,
|
||||
model: fixture.model.id,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
},
|
||||
],
|
||||
true,
|
||||
),
|
||||
)
|
||||
|
||||
const resolved = yield* Provider.use.getModel(
|
||||
ProviderV2.ID.make(mistralFixture.providerID),
|
||||
ModelV2.ID.make(fixture.model.id),
|
||||
)
|
||||
const sessionID = SessionID.make("session-test-mistral-reasoning")
|
||||
const agent = {
|
||||
name: "test",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
} satisfies Agent.Info
|
||||
|
||||
const user = {
|
||||
id: MessageID.make("msg_user-mistral-reasoning"),
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: agent.name,
|
||||
model: { providerID: ProviderV2.ID.make(mistralFixture.providerID), modelID: resolved.id },
|
||||
} satisfies SessionV1.User
|
||||
|
||||
const thinking = {
|
||||
type: "thinking",
|
||||
thinking: [
|
||||
{ type: "text", text: "thinking" },
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
{ type: "reference", reference_ids: [1, "source-2"] },
|
||||
],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
}
|
||||
|
||||
yield* drain({
|
||||
user,
|
||||
sessionID,
|
||||
model: resolved,
|
||||
agent,
|
||||
system: ["You are a helpful assistant."],
|
||||
messages: [
|
||||
{ role: "user", content: "Hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
providerOptions: { mistral: { thinking } },
|
||||
},
|
||||
{ type: "text", text: "Previous answer" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "Continue" },
|
||||
] satisfies ModelMessage[],
|
||||
tools: {},
|
||||
})
|
||||
|
||||
const capture = yield* Effect.promise(() => request)
|
||||
const messages = capture.body.messages as Array<Record<string, unknown>>
|
||||
expect(messages.find((message) => message.role === "assistant")).toEqual({
|
||||
role: "assistant",
|
||||
content: [thinking, { type: "text", text: "Previous answer" }],
|
||||
})
|
||||
}),
|
||||
{
|
||||
config: () => ({
|
||||
enabled_providers: [mistralFixture.providerID],
|
||||
provider: {
|
||||
[mistralFixture.providerID]: {
|
||||
options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
const alibabaQwenFixture = { providerID: "alibaba", modelID: "qwen-plus" }
|
||||
it.instance(
|
||||
"service stream cancellation cancels provider response body promptly",
|
||||
|
||||
@@ -521,6 +521,7 @@ export function getToolInfo(
|
||||
}
|
||||
}
|
||||
case "bash":
|
||||
case "shell":
|
||||
return {
|
||||
icon: "console",
|
||||
title: i18n.t("ui.tool.shell"),
|
||||
@@ -538,6 +539,7 @@ export function getToolInfo(
|
||||
title: i18n.t("ui.messagePart.title.write"),
|
||||
subtitle: input.filePath ? getFilename(input.filePath) : undefined,
|
||||
}
|
||||
case "patch":
|
||||
case "apply_patch":
|
||||
return {
|
||||
icon: "code-lines",
|
||||
@@ -729,8 +731,8 @@ export function renderable(part: PartType, showReasoningSummaries = true) {
|
||||
}
|
||||
|
||||
function toolDefaultOpen(tool: string, shell = false, edit = false) {
|
||||
if (tool === "bash") return shell
|
||||
if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit
|
||||
if (tool === "bash" || tool === "shell") return shell
|
||||
if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit
|
||||
}
|
||||
|
||||
export function partDefaultOpen(part: PartType, shell = false, edit = false) {
|
||||
@@ -1506,7 +1508,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) {
|
||||
}
|
||||
|
||||
export function getTool(name: string) {
|
||||
return state[name]?.render
|
||||
return state[name === "apply_patch" ? "patch" : name === "bash" ? "shell" : name]?.render
|
||||
}
|
||||
|
||||
export const ToolRegistry = {
|
||||
@@ -2101,7 +2103,7 @@ ToolRegistry.register({
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "bash",
|
||||
name: "shell",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const pending = () => props.status === "pending" || props.status === "running"
|
||||
@@ -2337,7 +2339,7 @@ ToolRegistry.register({
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "apply_patch",
|
||||
name: "patch",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
|
||||
import { parsePatch } from "diff"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
type LegacyDiff = {
|
||||
file: string
|
||||
@@ -13,7 +14,7 @@ type LegacyDiff = {
|
||||
}
|
||||
|
||||
type SnapshotDiff = SnapshotFileDiff & { file: string }
|
||||
type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff
|
||||
type ReviewDiff = SnapshotDiff | FileDiffInfo | VcsFileDiff | LegacyDiff
|
||||
export type DiffSource = Pick<LegacyDiff, "file" | "patch" | "before" | "after">
|
||||
|
||||
export type ViewDiff = {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
@@ -62,10 +63,10 @@ export type SessionReviewCommentActions = {
|
||||
|
||||
export type SessionReviewFocus = { file: string; id: string }
|
||||
|
||||
type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & {
|
||||
type RawReviewDiff = (SnapshotFileDiff | FileDiffInfo | VcsFileDiff) & {
|
||||
preloaded?: PreloadMultiFileDiffResult<any>
|
||||
}
|
||||
type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & {
|
||||
type ReviewDiff = ((SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff) & {
|
||||
preloaded?: PreloadMultiFileDiffResult<any>
|
||||
}
|
||||
type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Message as MessageType,
|
||||
Part as PartType,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { SessionStatus } from "@opencode-ai/sdk/v2"
|
||||
import { useData } from "../context"
|
||||
import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
@@ -90,7 +91,7 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
type SummaryDiff = SnapshotFileDiff & { file: string }
|
||||
type SummaryDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo
|
||||
|
||||
function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
|
||||
return typeof value.file === "string"
|
||||
|
||||
@@ -51,6 +51,8 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
|
||||
webfetch: "ui.tool.webfetch",
|
||||
websearch: "ui.tool.websearch",
|
||||
bash: "ui.tool.shell",
|
||||
shell: "ui.tool.shell",
|
||||
patch: "ui.tool.patch",
|
||||
apply_patch: "ui.tool.patch",
|
||||
question: "ui.tool.questions",
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user