chore: merge dev into v2 (#39290)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Jay V <air@live.ca> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Sebastian <hasta84@gmail.com> Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com> Co-authored-by: Test User <test@test.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com> Co-authored-by: Qiping Li <liqiping1991@gmail.com> Co-authored-by: liqiping <liqiping@msh.team> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com> Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com> Co-authored-by: Daniel Polito <danielbpolito@gmail.com> Co-authored-by: opencode <noreply@opencode.ai> Co-authored-by: Devin R Leopold <devin.leopold@gmail.com> Co-authored-by: Zach Bruggeman <mail@bruggie.com> Co-authored-by: Zach Bruggeman <zbruggeman@ramp.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: David Siewert <david1gruppenplan@gmail.com> Co-authored-by: Andrei Dziahel <develop7@develop7.info> Co-authored-by: adityachaudhary99 <adityaachaudhary2003@gmail.com> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: Matt Carey <mcarey@cloudflare.com>
This commit is contained in:
parent
7211c9934a
commit
302e9b45ab
263 changed files with 12516 additions and 4822 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
|
"x86_64-linux": "sha256-RFek0QoEEjsgbqmTE/SxQAmPtYyzs0IPR2ugFn5Okrs=",
|
||||||
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
|
"aarch64-linux": "sha256-BmAxapY1YrAFn7mVq3/6A9+6Au5UIvSqBboHMkyJH3I=",
|
||||||
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
|
"aarch64-darwin": "sha256-Sx3bGWQqLlgoa/RudJxanjSzhFRNklckT2ffnO2I5F4=",
|
||||||
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
|
"x86_64-darwin": "sha256-CMOhiisHNowg06qadvgg4K+60zrynglwiT0qKYQ4NiA="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
220
packages/app/V1_API_MIGRATION.md
Normal file
220
packages/app/V1_API_MIGRATION.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -20,7 +20,7 @@ const profiles = [
|
||||||
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
|
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
|
||||||
{
|
{
|
||||||
name: "multi patch",
|
name: "multi patch",
|
||||||
tool: "patch",
|
tool: "apply_patch",
|
||||||
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
|
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||||
userMessage(),
|
userMessage(),
|
||||||
assistantMessage(
|
assistantMessage(
|
||||||
[
|
[
|
||||||
toolPart(patchID, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
||||||
textPart(followingID, "Following incremental patch"),
|
textPart(followingID, "Following incremental patch"),
|
||||||
],
|
],
|
||||||
{ completed: false },
|
{ completed: false },
|
||||||
|
|
@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||||
partUpdated(
|
partUpdated(
|
||||||
toolPart(
|
toolPart(
|
||||||
patchID,
|
patchID,
|
||||||
"patch",
|
"apply_patch",
|
||||||
"running",
|
"running",
|
||||||
{ files: [first.filePath, second.filePath] },
|
{ files: [first.filePath, second.filePath] },
|
||||||
{ metadata: { files: [first, second] } },
|
{ metadata: { files: [first, second] } },
|
||||||
|
|
@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||||
partUpdated(
|
partUpdated(
|
||||||
toolPart(
|
toolPart(
|
||||||
patchID,
|
patchID,
|
||||||
"patch",
|
"apply_patch",
|
||||||
"completed",
|
"completed",
|
||||||
{ files: [first.filePath, second.filePath, third.filePath] },
|
{ files: [first.filePath, second.filePath, third.filePath] },
|
||||||
{ metadata: { files: [first, second, third] } },
|
{ metadata: { files: [first, second, third] } },
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,11 @@ test.describe("timeline tool state stability", () => {
|
||||||
}
|
}
|
||||||
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
|
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
|
||||||
const questionID = "prt_state_question"
|
const questionID = "prt_state_question"
|
||||||
|
const todoID = "prt_state_todo"
|
||||||
const initial = [
|
const initial = [
|
||||||
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
|
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
|
||||||
toolPart(questionID, "question", "pending", questionInput()),
|
toolPart(questionID, "question", "pending", questionInput()),
|
||||||
|
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||||
textPart("prt_state_following", "Following lightweight tools"),
|
textPart("prt_state_following", "Following lightweight tools"),
|
||||||
]
|
]
|
||||||
const childID = "ses_timeline_child"
|
const childID = "ses_timeline_child"
|
||||||
|
|
@ -47,6 +49,7 @@ test.describe("timeline tool state stability", () => {
|
||||||
await timeline.send(status("busy"), 120)
|
await timeline.send(status("busy"), 120)
|
||||||
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
||||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||||
|
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||||
|
|
||||||
const regionIDs = [
|
const regionIDs = [
|
||||||
"prt_state_webfetch",
|
"prt_state_webfetch",
|
||||||
|
|
@ -102,6 +105,7 @@ test.describe("timeline tool state stability", () => {
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
||||||
|
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||||
await expect(
|
await expect(
|
||||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,12 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
|
||||||
const messages = [user, ...assistants]
|
const messages = [user, ...assistants]
|
||||||
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
||||||
const lastID = userID
|
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("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
||||||
benchmark.setTimeout(180_000)
|
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 }
|
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await page.route(`**/session/${fixture.targetID}`, (route) =>
|
await page.route(`**/session/${fixture.targetID}`, (route) => {
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
|
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 installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
||||||
await page.goto(stressSessionHref(fixture.sourceID))
|
await page.goto(stressSessionHref(fixture.sourceID))
|
||||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
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,
|
parent: requests.filter((request) => request.type === "parent").length,
|
||||||
}
|
}
|
||||||
if (mode === "candidate") {
|
if (mode === "candidate") {
|
||||||
expect(requestCounts.parent).toBe(1)
|
expect(requestCounts.parent).toBe(0)
|
||||||
expect(historyGates).toBe(1)
|
expect(historyGates).toBe(0)
|
||||||
}
|
}
|
||||||
return { metrics, requestCounts, historyGateCount: historyGates }
|
return { metrics, requestCounts, historyGateCount: historyGates }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -295,7 +295,7 @@ function performanceTurn(index: number) {
|
||||||
messageID: assistantID,
|
messageID: assistantID,
|
||||||
type: "tool",
|
type: "tool",
|
||||||
callID: `call_0000_${suffix}_patch`,
|
callID: `call_0000_${suffix}_patch`,
|
||||||
tool: "patch",
|
tool: "apply_patch",
|
||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: { patchText: realisticPatch(index) },
|
input: { patchText: realisticPatch(index) },
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ function toolPart(
|
||||||
): MessagePart {
|
): MessagePart {
|
||||||
const metadata =
|
const metadata =
|
||||||
metadataOverride ??
|
metadataOverride ??
|
||||||
(tool === "patch"
|
(tool === "apply_patch"
|
||||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||||
: tool === "edit" || tool === "write"
|
: tool === "edit" || tool === "write"
|
||||||
? {
|
? {
|
||||||
|
|
@ -219,7 +219,7 @@ function turn(index: number): Message[] {
|
||||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||||
: []),
|
: []),
|
||||||
...(index % 8 === 0
|
...(index % 8 === 0
|
||||||
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||||
: []),
|
: []),
|
||||||
...(index % 7 === 0
|
...(index % 7 === 0
|
||||||
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||||
|
|
@ -269,6 +269,7 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
|
||||||
]).flat()
|
]).flat()
|
||||||
|
|
||||||
function renderable(part: MessagePart) {
|
function renderable(part: MessagePart) {
|
||||||
|
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||||
if (part.type === "text") return !!part.text.trim()
|
if (part.type === "text") return !!part.text.trim()
|
||||||
if (part.type === "reasoning") return !!part.text.trim()
|
if (part.type === "reasoning") return !!part.text.trim()
|
||||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
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 tabA.locator('[data-slot="tab-close"] button').click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
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()
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
||||||
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
||||||
|
|
@ -84,17 +85,21 @@ async function mockServers(page: Page, requests: string[]) {
|
||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
return sse(route)
|
||||||
if (url.pathname === "/session") return json(route, [current])
|
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||||
|
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||||
|
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||||
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
|
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||||
|
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||||
return json(route, [])
|
return json(route, [])
|
||||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||||
return json(route, {})
|
|
||||||
if (url.pathname === "/provider")
|
if (url.pathname === "/provider")
|
||||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||||
|
|
@ -116,7 +121,20 @@ async function mockServers(page: Page, requests: string[]) {
|
||||||
directory: current.directory,
|
directory: current.directory,
|
||||||
home: 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 === "/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, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
packages/app/e2e/regression/open-file-expand-folder.spec.ts
Normal file
132
packages/app/e2e/regression/open-file-expand-folder.spec.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/OpenFileExpand"
|
||||||
|
const projectID = "proj_open_file_expand"
|
||||||
|
const sessionID = "ses_open_file_expand"
|
||||||
|
const title = "Open file expand"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
|
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "open-file-expand",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: sessionID,
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vcsDiff: [],
|
||||||
|
fileList: (path) => {
|
||||||
|
if (path === "frontend\\" || path === "frontend") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "app.ts",
|
||||||
|
path: "frontend\\app.ts",
|
||||||
|
absolute: `${directory}/frontend/app.ts`,
|
||||||
|
type: "file" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (path) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "frontend",
|
||||||
|
path: "frontend\\",
|
||||||
|
absolute: `${directory}/frontend`,
|
||||||
|
type: "directory" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "README.md",
|
||||||
|
path: "README.md",
|
||||||
|
absolute: `${directory}/README.md`,
|
||||||
|
type: "file" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, server, sessionID }) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"settings.v3",
|
||||||
|
JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:layout",
|
||||||
|
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:review-panel-v2",
|
||||||
|
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, server, sessionID },
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
|
const panel = page.locator("#review-panel")
|
||||||
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
|
|
||||||
|
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
||||||
|
await expect(sidebar).toBeVisible()
|
||||||
|
|
||||||
|
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
|
||||||
|
await expect(frontendRow).toBeVisible()
|
||||||
|
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
|
||||||
|
await frontendRow.click()
|
||||||
|
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
|
||||||
|
|
||||||
|
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
|
||||||
|
await expect(appRow).toBeVisible()
|
||||||
|
await appRow.click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { installSseTransport } from "../utils/sse-transport"
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
const serverB = "http://127.0.0.1:4097"
|
||||||
|
|
@ -17,7 +18,7 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||||
|
|
||||||
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
|
await page.keyboard.press("Control+,")
|
||||||
|
|
||||||
const dialog = page.locator(".settings-v2-dialog")
|
const dialog = page.locator(".settings-v2-dialog")
|
||||||
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
|
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
|
||||||
|
|
@ -58,7 +59,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||||
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
||||||
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
|
await page.keyboard.press("Control+,")
|
||||||
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
||||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||||
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||||
|
|
@ -180,10 +181,36 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||||
return json(route, true)
|
return json(route, true)
|
||||||
}
|
}
|
||||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event") 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 === "/global/health") return json(route, { healthy: true })
|
||||||
if (url.pathname === "/session/status") return json(route, {})
|
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
|
||||||
if (url.pathname === "/session") return json(route, sessions)
|
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}`)
|
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
||||||
if (current) return json(route, current)
|
if (current) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
|
|
@ -216,7 +243,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||||
directory,
|
directory,
|
||||||
home: 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 === "/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, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
const serverB = "http://127.0.0.1:4097"
|
||||||
|
|
@ -57,15 +58,19 @@ async function mockServers(page: Page) {
|
||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
return sse(route, url.pathname === "/api/event")
|
||||||
if (url.pathname === "/session/status")
|
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||||
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
|
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||||
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 (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||||
return json(route, [])
|
return json(route, [])
|
||||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||||
|
|
@ -90,7 +95,20 @@ async function mockServers(page: Page) {
|
||||||
directory: current.directory,
|
directory: current.directory,
|
||||||
home: 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 === "/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, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +122,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: ": ok\n\n" })
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
|
||||||
async function openReview(page: Page) {
|
async function openReview(page: Page) {
|
||||||
await page.setViewportSize({ width: 700, height: 900 })
|
await page.setViewportSize({ width: 700, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: "proj_review_line_comment_regression",
|
id: "proj_review_line_comment_regression",
|
||||||
|
|
@ -143,9 +144,9 @@ async function openReview(page: Page) {
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
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()
|
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"]')
|
const review = page.locator('[data-component="session-review"]')
|
||||||
await expectAppVisible(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: "nested.ts" })).toHaveCount(1)
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
await expect(sidebarToggle).toBeDisabled()
|
await expect(sidebarToggle).toBeDisabled()
|
||||||
await panel.getByRole("tab", { name: /Review/ }).click()
|
await panel.locator("#session-side-panel-review-tab").click()
|
||||||
await expect(sidebarToggle).toBeEnabled()
|
await expect(sidebarToggle).toBeEnabled()
|
||||||
await panel.getByRole("tab", { name: "Open file" }).click()
|
await panel.getByRole("tab", { name: "Open file" }).click()
|
||||||
await page.keyboard.press("Control+w")
|
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) {
|
async function selectMode(page: Page, current: string, next: string) {
|
||||||
await page.getByRole("button", { name: current }).click()
|
await page.getByRole("button", { name: current }).click()
|
||||||
await page.getByRole("option", { name: next }).click()
|
await page.getByRole("option", { name: next }).dispatchEvent("click")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectFile(page: Page, file: string) {
|
async function selectFile(page: Page, file: string) {
|
||||||
|
|
@ -65,6 +65,7 @@ async function switchSession(page: Page, title: string) {
|
||||||
|
|
||||||
async function setup(page: Page) {
|
async function setup(page: Page) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v1",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,12 @@ const branchDiffs = [
|
||||||
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
|
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
|
||||||
|
const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
|
||||||
let detailVersion = 1
|
let detailVersion = 1
|
||||||
let detailFailures = 1
|
let detailFailures = 1
|
||||||
await page.setViewportSize({ width: 1400, height: 900 })
|
await page.setViewportSize({ width: 1400, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v1",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
|
|
@ -55,7 +57,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sessionStatus: { [sessionID]: { type: "idle" } },
|
sessionStatus: () => sessionStatus,
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
events: () => events.splice(0, 1),
|
events: () => events.splice(0, 1),
|
||||||
eventRetry: 16,
|
eventRetry: 16,
|
||||||
|
|
@ -64,7 +66,10 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
|
body: JSON.stringify({
|
||||||
|
branch: "review-pane-performance",
|
||||||
|
default_branch: "dev",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route("**/vcs/diff**", (route) => {
|
await page.route("**/vcs/diff**", (route) => {
|
||||||
|
|
@ -86,15 +91,51 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
await page.route("**/pty", (route) =>
|
await page.route("**/pty*", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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) =>
|
await page.route("**/pty/pty_review_terminal*", (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: {
|
||||||
|
id: "pty_review_terminal",
|
||||||
|
title: "Terminal 1",
|
||||||
|
command: "cmd.exe",
|
||||||
|
args: [],
|
||||||
|
cwd: directory,
|
||||||
|
status: "running",
|
||||||
|
pid: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: { ticket: "e2e-ticket", expires_in: 60 },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
|
|
@ -143,6 +184,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||||
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
|
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
|
||||||
await expect(preview).toContainText("after-1")
|
await expect(preview).toContainText("after-1")
|
||||||
detailVersion = 2
|
detailVersion = 2
|
||||||
|
sessionStatus[sessionID] = { type: "busy" }
|
||||||
events.push(statusEvent("busy"))
|
events.push(statusEvent("busy"))
|
||||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||||
const refreshedDiff = page.waitForRequest((request) => {
|
const refreshedDiff = page.waitForRequest((request) => {
|
||||||
|
|
@ -152,6 +194,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
sessionStatus[sessionID] = { type: "idle" }
|
||||||
events.push(statusEvent("idle"))
|
events.push(statusEvent("idle"))
|
||||||
await refreshedDiff
|
await refreshedDiff
|
||||||
await expect(preview).toContainText("after-2")
|
await expect(preview).toContainText("after-2")
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async (
|
||||||
const pathBlocked = new Promise<void>((resolve) => {
|
const pathBlocked = new Promise<void>((resolve) => {
|
||||||
releasePath = resolve
|
releasePath = resolve
|
||||||
})
|
})
|
||||||
await page.route("**/path?*", async (route) => {
|
await page.route("**/api/path?*", async (route) => {
|
||||||
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
|
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
|
||||||
await pathBlocked
|
await pathBlocked
|
||||||
return route.fallback()
|
return route.fallback()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => {
|
||||||
const rejectRequests: string[] = []
|
const rejectRequests: string[] = []
|
||||||
page.on("request", (request) => {
|
page.on("request", (request) => {
|
||||||
if (request.method() !== "POST") return
|
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()
|
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()
|
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||||
const reply = page.waitForRequest(
|
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()
|
await question.getByRole("button", { name: "Submit" }).click()
|
||||||
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
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")
|
const reply = page.waitForRequest((request) => request.method() === "POST")
|
||||||
await permission.getByRole("button", { name: "Allow once" }).click()
|
await permission.getByRole("button", { name: "Allow once" }).click()
|
||||||
const request = await reply
|
const request = await reply
|
||||||
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
|
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
|
||||||
expect(request.postDataJSON()).toEqual({ response: "once" })
|
expect(request.postDataJSON()).toEqual({ reply: "once" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||||
|
|
@ -170,6 +173,7 @@ async function mockServer(
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => {
|
||||||
assistantMessage([
|
assistantMessage([
|
||||||
toolPart(
|
toolPart(
|
||||||
id,
|
id,
|
||||||
"patch",
|
"apply_patch",
|
||||||
"completed",
|
"completed",
|
||||||
{ files: ["src/a.ts"] },
|
{ files: ["src/a.ts"] },
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
|
||||||
assistantMessage([
|
assistantMessage([
|
||||||
toolPart(
|
toolPart(
|
||||||
patchID,
|
patchID,
|
||||||
"patch",
|
"apply_patch",
|
||||||
"completed",
|
"completed",
|
||||||
{ files: files.map((file) => file.filePath) },
|
{ files: files.map((file) => file.filePath) },
|
||||||
{ metadata: { files } },
|
{ metadata: { files } },
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ test.describe("session timeline projection", () => {
|
||||||
editPart("prt_edit"),
|
editPart("prt_edit"),
|
||||||
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
|
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
|
||||||
patchPart("prt_patch"),
|
patchPart("prt_patch"),
|
||||||
|
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||||
toolPart(
|
toolPart(
|
||||||
"prt_question",
|
"prt_question",
|
||||||
"question",
|
"question",
|
||||||
|
|
@ -64,6 +65,7 @@ test.describe("session timeline projection", () => {
|
||||||
]) {
|
]) {
|
||||||
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
||||||
}
|
}
|
||||||
|
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||||
|
|
@ -247,7 +249,7 @@ function editPart(id: string) {
|
||||||
function patchPart(id: string) {
|
function patchPart(id: string) {
|
||||||
return toolPart(
|
return toolPart(
|
||||||
id,
|
id,
|
||||||
"patch",
|
"apply_patch",
|
||||||
"completed",
|
"completed",
|
||||||
{ files: ["src/a.ts", "src/b.ts"] },
|
{ files: ["src/a.ts", "src/b.ts"] },
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
} from "../performance/timeline-stability/fixture"
|
} from "../performance/timeline-stability/fixture"
|
||||||
|
|
||||||
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
|
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
|
||||||
const ordinary = ["bash", "edit", "write", "patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
||||||
const parts = ordinary.map((tool, index) =>
|
const parts = ordinary.map((tool, index) =>
|
||||||
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
|
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
|
||||||
)
|
)
|
||||||
|
|
@ -17,11 +17,13 @@ test("renders every tool error outcome without leaking hidden tools", async ({ p
|
||||||
error: "The user dismissed this question",
|
error: "The user dismissed this question",
|
||||||
}),
|
}),
|
||||||
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
|
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
|
||||||
|
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
|
||||||
)
|
)
|
||||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||||
|
|
||||||
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
||||||
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
||||||
|
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
|
||||||
for (let index = 0; index < ordinary.length; index++) {
|
for (let index = 0; index < ordinary.length; index++) {
|
||||||
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
|
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +90,7 @@ function questionInput() {
|
||||||
function errorInput(tool: string) {
|
function errorInput(tool: string) {
|
||||||
if (tool === "bash") return { command: "exit 1" }
|
if (tool === "bash") return { command: "exit 1" }
|
||||||
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
|
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
|
||||||
if (tool === "patch") return { files: ["src/error.ts"] }
|
if (tool === "apply_patch") return { files: ["src/error.ts"] }
|
||||||
if (tool === "webfetch") return { url: "https://example.com" }
|
if (tool === "webfetch") return { url: "https://example.com" }
|
||||||
if (tool === "websearch") return { query: "failure" }
|
if (tool === "websearch") return { query: "failure" }
|
||||||
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
|
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
|
||||||
|
|
|
||||||
190
packages/app/e2e/regression/session-todo-dock-navigation.spec.ts
Normal file
190
packages/app/e2e/regression/session-todo-dock-navigation.spec.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/TodoDockNavigation"
|
||||||
|
const projectID = "proj_todo_dock_navigation"
|
||||||
|
const sourceID = "ses_todo_dock_source"
|
||||||
|
const otherID = "ses_todo_dock_other"
|
||||||
|
const sourceTitle = "Todo dock animation"
|
||||||
|
const otherTitle = "Separate session"
|
||||||
|
|
||||||
|
const activeTodos = [
|
||||||
|
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
|
||||||
|
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
|
||||||
|
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
|
||||||
|
]
|
||||||
|
|
||||||
|
type EventPayload = {
|
||||||
|
directory: string
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
||||||
|
|
||||||
|
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000)
|
||||||
|
const events: EventPayload[] = []
|
||||||
|
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||||
|
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
|
||||||
|
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "todo-dock-navigation",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: {
|
||||||
|
"claude-opus-4-6": {
|
||||||
|
id: "claude-opus-4-6",
|
||||||
|
name: "Claude Opus 4.6",
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||||
|
},
|
||||||
|
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||||
|
sessionStatus: { [sourceID]: { type: "busy" } },
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
events: () => events.splice(0, 1),
|
||||||
|
eventRetry: 16,
|
||||||
|
sessionStatus: () => sessionStatus,
|
||||||
|
todos: (sessionID) => todos[sessionID] ?? [],
|
||||||
|
})
|
||||||
|
await configurePage(page)
|
||||||
|
|
||||||
|
await page.goto(sessionHref(sourceID))
|
||||||
|
await expectSessionTitle(page, sourceTitle)
|
||||||
|
const dock = page.locator('[data-component="session-todo-dock"]')
|
||||||
|
await expect(dock).toHaveCount(0)
|
||||||
|
|
||||||
|
sessionStatus[sourceID] = { type: "busy" }
|
||||||
|
events.push(statusEvent(sourceID, "busy"))
|
||||||
|
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||||
|
|
||||||
|
await page.waitForTimeout(700)
|
||||||
|
const opening = sampleDock(page, 1_000)
|
||||||
|
todos[sourceID] = activeTodos
|
||||||
|
events.push(todoEvent(sourceID, activeTodos))
|
||||||
|
await expect(dock).toBeVisible()
|
||||||
|
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||||
|
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||||
|
|
||||||
|
await switchSession(page, otherID, otherTitle)
|
||||||
|
await expect(dock).toHaveCount(0)
|
||||||
|
|
||||||
|
const returningOpen = sampleDock(page, 700)
|
||||||
|
await switchSession(page, sourceID, sourceTitle)
|
||||||
|
const openSamples = (await returningOpen).filter((sample) => sample.present)
|
||||||
|
expect(openSamples.length).toBeGreaterThan(0)
|
||||||
|
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
|
||||||
|
expect(openSamples[0]!.height).toBeGreaterThan(70)
|
||||||
|
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||||
|
|
||||||
|
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
|
||||||
|
const closing = sampleDock(page, 1_000)
|
||||||
|
todos[sourceID] = completedTodos
|
||||||
|
events.push(todoEvent(sourceID, completedTodos))
|
||||||
|
await expect(dock).toHaveCount(0)
|
||||||
|
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||||
|
todos[sourceID] = []
|
||||||
|
events.push(todoEvent(sourceID, []))
|
||||||
|
|
||||||
|
await switchSession(page, otherID, otherTitle)
|
||||||
|
const returningEmpty = sampleDock(page, 700)
|
||||||
|
await switchSession(page, sourceID, sourceTitle)
|
||||||
|
await expect(dock).toHaveCount(0)
|
||||||
|
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
function session(id: string, title: string, created: number) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
slug: id,
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created, updated: created },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
|
||||||
|
return {
|
||||||
|
directory,
|
||||||
|
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
|
||||||
|
return {
|
||||||
|
directory,
|
||||||
|
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configurePage(page: Page) {
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, dirBase64, server, sessionIDs }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionHref(sessionID: string) {
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||||
|
const href = sessionHref(sessionID)
|
||||||
|
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleDock(page: Page, duration: number) {
|
||||||
|
return page.evaluate(async (duration) => {
|
||||||
|
const samples: { present: boolean; height: number; opacity: number }[] = []
|
||||||
|
const start = performance.now()
|
||||||
|
while (performance.now() - start < duration) {
|
||||||
|
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
|
||||||
|
const clip = dock?.parentElement?.parentElement
|
||||||
|
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
|
||||||
|
samples.push({
|
||||||
|
present: !!dock,
|
||||||
|
height: clip?.getBoundingClientRect().height ?? 0,
|
||||||
|
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
|
||||||
|
})
|
||||||
|
await new Promise(requestAnimationFrame)
|
||||||
|
}
|
||||||
|
return samples
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page } from "@playwright/test"
|
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"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const directory = "C:/OpenCode/SubagentNavigation"
|
const directory = "C:/OpenCode/SubagentNavigation"
|
||||||
|
|
@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) {
|
||||||
events,
|
events,
|
||||||
eventRetry: events ? 16 : undefined,
|
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.
|
// matching a subagent session that has not been loaded into the list cache yet.
|
||||||
await page.route(
|
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) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
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)
|
await configurePage(page)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const server = "http://127.0.0.1:4096"
|
const server = "http://127.0.0.1:4096"
|
||||||
const sessionA = session("ses_tab_a", "Tab A session")
|
const sessionA = session("ses_tab_a", "Tab A session")
|
||||||
const sessionB = session("ses_tab_b", "Tab B session")
|
const sessionB = session("ses_tab_b", "Tab B session")
|
||||||
|
const sessionC = session("ses_tab_c", "Tab C session")
|
||||||
|
const unresolvedSessionID = "ses_tab_unresolved"
|
||||||
|
|
||||||
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
||||||
await mockServer(page)
|
await mockServer(page)
|
||||||
|
|
@ -39,6 +42,34 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page })
|
||||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keyboard navigation follows the visible tab order", async ({ page }) => {
|
||||||
|
await mockServer(page)
|
||||||
|
await page.addInitScript(
|
||||||
|
({ server, sessionA, unresolved, sessionC }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify([
|
||||||
|
{ type: "session", server, sessionId: sessionA },
|
||||||
|
{ type: "session", server, sessionId: unresolved },
|
||||||
|
{ type: "session", server, sessionId: sessionC },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id },
|
||||||
|
)
|
||||||
|
|
||||||
|
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||||
|
const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
|
||||||
|
await page.goto(hrefA)
|
||||||
|
await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
|
||||||
|
await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+Alt+ArrowRight")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
|
})
|
||||||
|
|
||||||
function session(id: string, title: string) {
|
function session(id: string, title: string) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
@ -52,22 +83,29 @@ function session(id: string, title: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mockServer(page: Page) {
|
async function mockServer(page: Page) {
|
||||||
const sessions = [sessionA, sessionB]
|
const sessions = [sessionA, sessionB, sessionC]
|
||||||
await page.route("**/*", async (route) => {
|
await page.route("**/*", async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
if (url.origin !== server) return route.fallback()
|
if (url.origin !== server) return route.fallback()
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||||
|
return new Promise(() => {})
|
||||||
|
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 === "/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}`)
|
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
||||||
if (byId) return json(route, byId)
|
if (byId) return json(route, byId)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||||
return json(route, [])
|
return json(route, [])
|
||||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||||
return json(route, {})
|
|
||||||
if (url.pathname === "/provider")
|
if (url.pathname === "/provider")
|
||||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||||
|
|
@ -89,7 +127,20 @@ async function mockServer(page: Page) {
|
||||||
directory: sessionA.directory,
|
directory: sessionA.directory,
|
||||||
home: 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 === "/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, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
|
|
@ -46,25 +47,30 @@ test.beforeEach(async ({ page }) => {
|
||||||
],
|
],
|
||||||
pageMessages: () => ({ items: [] }),
|
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({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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) =>
|
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
|
||||||
)
|
|
||||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
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(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
})
|
})
|
||||||
|
|
@ -95,12 +101,12 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
|
||||||
const ghostty = Promise.withResolvers<void>()
|
const ghostty = Promise.withResolvers<void>()
|
||||||
const release = Promise.withResolvers<void>()
|
const release = Promise.withResolvers<void>()
|
||||||
const created = { count: 0 }
|
const created = { count: 0 }
|
||||||
await page.route("**/pty", (route) => {
|
await page.route("**/api/pty*", (route) => {
|
||||||
created.count += 1
|
created.count += 1
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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) => {
|
await page.route(/ghostty-web/, async (route) => {
|
||||||
|
|
@ -155,27 +161,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 }) => {
|
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
|
||||||
const created = { count: 0 }
|
const created = { count: 0 }
|
||||||
await page.route("**/pty", (route) => {
|
await page.route("**/api/pty*", (route) => {
|
||||||
created.count += 1
|
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({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify(next),
|
body: JSON.stringify({ location: ptyLocation(), data: next }),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
await page.route(`**/pty/${newPtyID}`, (route) =>
|
await page.route(`**/api/pty/${newPtyID}*`, (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
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({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
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 page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, "Terminal composer focus")
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
@ -207,3 +217,11 @@ function seedCachedTerminal(page: Page) {
|
||||||
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ptyLocation() {
|
||||||
|
return { directory, project: { id: projectID, directory } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ptyInfo(id: string, title: string) {
|
||||||
|
return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const title = "Hidden terminal regression"
|
||||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 900 })
|
await page.setViewportSize({ width: 1400, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
|
|
@ -43,17 +44,53 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||||
],
|
],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.route("**/pty", (route) =>
|
await page.route("**/api/pty*", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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) =>
|
await page.route("**/api/pty/pty_hidden_terminal*", (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
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 page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
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"]')
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
await expect(terminal).toBeVisible()
|
await expect(terminal).toBeVisible()
|
||||||
await expect.poll(() => connections.length).toBe(1)
|
await expect.poll(() => connections.length).toBe(1)
|
||||||
|
const connection = new URL(connections[0]!)
|
||||||
|
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
|
||||||
|
expect(connection.searchParams.get("location[directory]")).toBe(directory)
|
||||||
|
expect(connection.searchParams.get("ticket")).toBeNull()
|
||||||
await writeProbe(page)
|
await writeProbe(page)
|
||||||
|
|
||||||
await switchTab(page, titleB)
|
await switchTab(page, titleB)
|
||||||
|
|
@ -62,6 +66,7 @@ async function readProbe(page: Page) {
|
||||||
|
|
||||||
async function setup(page: Page) {
|
async function setup(page: Page) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
|
|
@ -85,26 +90,33 @@ async function setup(page: Page) {
|
||||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.route("**/pty", (route) =>
|
await page.route("**/api/pty*", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route(`**/pty/${ptyID}`, (route) =>
|
await page.route(`**/api/pty/${ptyID}*`, (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
|
||||||
)
|
|
||||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
|
||||||
route.fulfill({
|
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,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
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[] = []
|
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())
|
connections.push(ws.url())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -143,3 +155,11 @@ function session(id: string, title: string, created: number) {
|
||||||
function sessionHref(sessionID: string) {
|
function sessionHref(sessionID: string) {
|
||||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
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 }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ function toolPart(
|
||||||
outputLength = 160,
|
outputLength = 160,
|
||||||
): MessagePart {
|
): MessagePart {
|
||||||
const metadata =
|
const metadata =
|
||||||
tool === "patch"
|
tool === "apply_patch"
|
||||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||||
: tool === "edit" || tool === "write"
|
: tool === "edit" || tool === "write"
|
||||||
? {
|
? {
|
||||||
|
|
@ -199,7 +199,7 @@ function turn(index: number): Message[] {
|
||||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||||
: []),
|
: []),
|
||||||
...(index % 8 === 0
|
...(index % 8 === 0
|
||||||
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||||
: []),
|
: []),
|
||||||
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
|
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
|
||||||
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||||
|
|
@ -229,6 +229,7 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||||
]).flat()
|
]).flat()
|
||||||
|
|
||||||
function renderable(part: MessagePart) {
|
function renderable(part: MessagePart) {
|
||||||
|
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||||
if (part.type === "text") return !!part.text.trim()
|
if (part.type === "text") return !!part.text.trim()
|
||||||
if (part.type === "reasoning") return !!part.text.trim()
|
if (part.type === "reasoning") return !!part.text.trim()
|
||||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||||
|
|
|
||||||
97
packages/app/e2e/user-story/model-selection-flow.spec.ts
Normal file
97
packages/app/e2e/user-story/model-selection-flow.spec.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/NewProject"
|
||||||
|
|
||||||
|
test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
|
||||||
|
let connectedGo = false
|
||||||
|
let pendingGo = false
|
||||||
|
const connections: Array<{ integrationID: string; body: unknown }> = []
|
||||||
|
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: "proj_model_selection_flow",
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "NewProject",
|
||||||
|
time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: () => ({
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: {
|
||||||
|
"free-model": {
|
||||||
|
id: "free-model",
|
||||||
|
name: "Free Model",
|
||||||
|
cost: { input: 0, output: 0 },
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "opencode-go",
|
||||||
|
name: "OpenCode Go",
|
||||||
|
models: {
|
||||||
|
"go-model-1": {
|
||||||
|
id: "go-model-1",
|
||||||
|
name: "Go Model 1",
|
||||||
|
cost: { input: 1, output: 1 },
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "free-model" },
|
||||||
|
}),
|
||||||
|
integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
|
||||||
|
onConnectKey: (input) => {
|
||||||
|
connections.push(input)
|
||||||
|
if (input.integrationID === "opencode-go") pendingGo = true
|
||||||
|
},
|
||||||
|
onInstanceDispose: () => {
|
||||||
|
if (pendingGo) connectedGo = true
|
||||||
|
},
|
||||||
|
sessions: [],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
fileList: (path) =>
|
||||||
|
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
|
||||||
|
findFiles: () => ["NewProject"],
|
||||||
|
})
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto("/")
|
||||||
|
const addProject = page.locator('[data-action="home-add-project-row"]')
|
||||||
|
await expectAppVisible(addProject)
|
||||||
|
await addProject.click()
|
||||||
|
await page.locator("[data-directory-path]").click()
|
||||||
|
|
||||||
|
await page.locator('[data-action="home-new-session"]').click()
|
||||||
|
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
|
||||||
|
|
||||||
|
const modelControl = page.locator('[data-action="prompt-model"]')
|
||||||
|
await modelControl.click()
|
||||||
|
await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")
|
||||||
|
|
||||||
|
await page.locator('[data-provider-id="opencode-go"]').click()
|
||||||
|
await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
|
||||||
|
await page.locator('[data-action="provider-connect-submit"]').click()
|
||||||
|
await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
|
||||||
|
expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }])
|
||||||
|
|
||||||
|
await expect(modelControl).toHaveAttribute("data-control-type", "popover")
|
||||||
|
await modelControl.click()
|
||||||
|
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
|
||||||
|
await expect(goModel).toBeVisible()
|
||||||
|
await goModel.click()
|
||||||
|
|
||||||
|
await expect(modelControl).toContainText("Go Model 1")
|
||||||
|
})
|
||||||
|
|
@ -5,7 +5,10 @@ const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mc
|
||||||
|
|
||||||
export interface MockServerConfig {
|
export interface MockServerConfig {
|
||||||
protocol?: "v1" | "v2"
|
protocol?: "v1" | "v2"
|
||||||
provider: unknown
|
provider: unknown | (() => unknown)
|
||||||
|
integrationMethods?: Record<string, unknown[]>
|
||||||
|
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
|
||||||
|
onInstanceDispose?: () => void
|
||||||
directory: string
|
directory: string
|
||||||
project: unknown
|
project: unknown
|
||||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||||
|
|
@ -18,19 +21,19 @@ export interface MockServerConfig {
|
||||||
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
||||||
events?: () => unknown[]
|
events?: () => unknown[]
|
||||||
eventRetry?: number
|
eventRetry?: number
|
||||||
|
todos?: (sessionID: string) => unknown[]
|
||||||
permissions?: unknown[] | (() => unknown[])
|
permissions?: unknown[] | (() => unknown[])
|
||||||
questions?: unknown[] | (() => unknown[])
|
questions?: unknown[] | (() => unknown[])
|
||||||
fileList?: (path: string) => unknown | Promise<unknown>
|
fileList?: (path: string) => unknown | Promise<unknown>
|
||||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||||
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
||||||
sessionStatus?: unknown
|
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
const cursors = new Map<string, string>()
|
const cursors = new Map<string, string>()
|
||||||
let nextCursor = 0
|
let nextCursor = 0
|
||||||
const staticRoutes: Record<string, unknown> = {
|
const staticRoutes: Record<string, unknown> = {
|
||||||
"/provider": config.provider,
|
|
||||||
"/path": {
|
"/path": {
|
||||||
state: config.directory,
|
state: config.directory,
|
||||||
config: config.directory,
|
config: config.directory,
|
||||||
|
|
@ -60,7 +63,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
route,
|
route,
|
||||||
path === "/api/event"
|
path === "/api/event"
|
||||||
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
||||||
: events,
|
: [
|
||||||
|
...(path === "/global/event"
|
||||||
|
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
|
||||||
|
: []),
|
||||||
|
...(events ?? []),
|
||||||
|
],
|
||||||
config.eventRetry,
|
config.eventRetry,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -69,11 +77,27 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
if (path === "/api/health" && config.protocol === "v2")
|
if (path === "/api/health" && config.protocol === "v2")
|
||||||
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||||
|
if (path === "/provider")
|
||||||
|
return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
|
||||||
|
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
|
||||||
|
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
|
||||||
|
if (legacyAuth && route.request().method() === "PUT") {
|
||||||
|
config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
|
if (path === "/instance/dispose" && route.request().method() === "POST") {
|
||||||
|
config.onInstanceDispose?.()
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
if (path === "/permission")
|
if (path === "/permission")
|
||||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||||
if (path === "/question")
|
if (path === "/question")
|
||||||
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
||||||
if (path === "/session/status") return json(route, config.sessionStatus ?? {})
|
if (path === "/session/status")
|
||||||
|
return json(
|
||||||
|
route,
|
||||||
|
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}),
|
||||||
|
)
|
||||||
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||||
if (path === "/file" && config.fileList)
|
if (path === "/file" && config.fileList)
|
||||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||||
|
|
@ -120,8 +144,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
location: location(config),
|
location: location(config),
|
||||||
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
|
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
|
||||||
})
|
})
|
||||||
if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
|
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
|
||||||
|
if (integrationConnect && route.request().method() === "POST") {
|
||||||
|
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
|
}
|
||||||
if (path === "/api/project") return json(route, [config.project])
|
if (path === "/api/project") return json(route, [config.project])
|
||||||
if (path === "/api/project/current")
|
if (path === "/api/project/current")
|
||||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||||
|
|
@ -199,6 +226,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
}
|
}
|
||||||
|
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
|
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||||
route.request().method() === "POST"
|
route.request().method() === "POST"
|
||||||
|
|
@ -237,6 +270,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
return json(route, message)
|
return json(route, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
||||||
|
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||||
|
|
||||||
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,14 @@ export async function installSseTransport<T>(
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||||
)
|
)
|
||||||
|
if (url.pathname === "/global/event")
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(
|
||||||
|
frame({
|
||||||
|
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
request.signal.addEventListener(
|
request.signal.addEventListener(
|
||||||
"abort",
|
"abort",
|
||||||
() => {
|
() => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.4",
|
"version": "1.18.8",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
@ -53,10 +53,10 @@
|
||||||
"@dnd-kit/helpers": "0.5.0",
|
"@dnd-kit/helpers": "0.5.0",
|
||||||
"@dnd-kit/solid": "0.5.0",
|
"@dnd-kit/solid": "0.5.0",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "1.18.5",
|
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||||
"@opencode-ai/session-ui": "workspace:*",
|
"@opencode-ai/session-ui": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@pierre/trees": "1.0.0-beta.4",
|
"@pierre/trees": "1.0.0-beta.4",
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,8 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref }
|
||||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||||
|
|
||||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||||
import { NewHome, LegacyHome } from "@/pages/home"
|
import { NewHome } from "@/pages/home"
|
||||||
|
import { LegacyHome } from "@/pages/home/legacy-home"
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
||||||
|
|
@ -236,6 +237,30 @@ function UiI18nBridge(props: ParentProps) {
|
||||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LayoutCompatibility(props: ParentProps) {
|
||||||
|
const global = useGlobal()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const server = useServer()
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (settings.general.newLayoutDesigns()) return
|
||||||
|
const current = server.current
|
||||||
|
if (!current) return
|
||||||
|
const protocol = global.ensureServerCtx(current).sdk.protocolKind()
|
||||||
|
if (protocol !== "v2") return
|
||||||
|
const next = global.servers.list().find((s) => {
|
||||||
|
if (ServerConnection.key(s) === ServerConnection.key(current)) return false
|
||||||
|
return global.ensureServerCtx(s).sdk.protocolKind() !== "v2"
|
||||||
|
})
|
||||||
|
if (!next) return
|
||||||
|
navigate("/")
|
||||||
|
queueMicrotask(() => server.setActive(ServerConnection.key(next)))
|
||||||
|
})
|
||||||
|
|
||||||
|
return <>{props.children}</>
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
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 { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { createMemo, onCleanup } from "solid-js"
|
import { createMemo, onCleanup } from "solid-js"
|
||||||
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
|
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 { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
|
||||||
export type CommandPaletteEntry = {
|
export type CommandPaletteEntry = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -144,8 +146,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||||
server: ServerConnection.key(serverSDK.server),
|
server: ServerConnection.key(serverSDK.server),
|
||||||
opened: serverCtx.projects.list,
|
opened: serverCtx.projects.list,
|
||||||
stored: () => serverCtx.sync.data.project,
|
stored: () => serverCtx.sync.data.project,
|
||||||
load: (search, signal) =>
|
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||||
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
|
|
||||||
untitled: () => language.t("command.session.new"),
|
untitled: () => language.t("command.session.new"),
|
||||||
category: () => language.t("command.category.session"),
|
category: () => language.t("command.category.session"),
|
||||||
})
|
})
|
||||||
|
|
@ -219,7 +220,7 @@ export function createServerSessionEntries(props: {
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
opened: () => LocalProject[]
|
opened: () => LocalProject[]
|
||||||
stored: () => Project[]
|
stored: () => Project[]
|
||||||
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
|
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
|
||||||
untitled: () => string
|
untitled: () => string
|
||||||
category: () => string
|
category: () => string
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -255,7 +256,8 @@ export function createServerSessionEntries(props: {
|
||||||
return props
|
return props
|
||||||
.load(search, current.signal)
|
.load(search, current.signal)
|
||||||
.then((result) =>
|
.then((result) =>
|
||||||
(result.data ?? [])
|
result.data
|
||||||
|
.map(normalizeSessionInfo)
|
||||||
.filter((session) => !session.time.archived)
|
.filter((session) => !session.time.archived)
|
||||||
.map((session) => {
|
.map((session) => {
|
||||||
const project =
|
const project =
|
||||||
|
|
@ -264,7 +266,7 @@ export function createServerSessionEntries(props: {
|
||||||
id: `session:${props.server}:${session.id}`,
|
id: `session:${props.server}:${session.id}`,
|
||||||
type: "session" as const,
|
type: "session" as const,
|
||||||
title: session.title || props.untitled(),
|
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(),
|
category: props.category(),
|
||||||
directory: session.directory,
|
directory: session.directory,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
|
|
|
||||||
|
|
@ -79,8 +79,7 @@ export function DialogHomeCommandPaletteV2(props: {
|
||||||
server: ServerConnection.key(props.server),
|
server: ServerConnection.key(props.server),
|
||||||
opened: serverCtx.projects.list,
|
opened: serverCtx.projects.list,
|
||||||
stored: () => serverCtx.sync.data.project,
|
stored: () => serverCtx.sync.data.project,
|
||||||
load: (search, signal) =>
|
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||||
serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
|
|
||||||
untitled: () => language.t("command.session.new"),
|
untitled: () => language.t("command.session.new"),
|
||||||
category: () => language.t("command.category.session"),
|
category: () => language.t("command.category.session"),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
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 { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
Switch,
|
Switch,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
|
@ -35,8 +36,10 @@ import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
const CUSTOM_ID = "_custom"
|
const CUSTOM_ID = "_custom"
|
||||||
|
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||||
|
|
||||||
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
||||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||||
|
|
@ -154,7 +157,7 @@ function ProviderPicker(props: {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
if (settings.general.newLayoutDesigns())
|
if (settings.general.newLayoutDesigns())
|
||||||
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
||||||
const providers = useProviders(props.directory)
|
const providers = useProviders(() => props.directory?.())
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||||
|
|
@ -226,10 +229,8 @@ function ProviderPickerV2(props: {
|
||||||
onSelect: (provider: string) => void
|
onSelect: (provider: string) => void
|
||||||
onPrepare?: () => void
|
onPrepare?: () => void
|
||||||
}) {
|
}) {
|
||||||
const providers = useProviders(props.directory)
|
const providers = useProviders(() => props.directory?.())
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSync = useServerSync()
|
|
||||||
const serverSDK = useServerSDK()
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
filter: "",
|
filter: "",
|
||||||
active: undefined as string | undefined,
|
active: undefined as string | undefined,
|
||||||
|
|
@ -266,19 +267,7 @@ function ProviderPickerV2(props: {
|
||||||
|
|
||||||
const connect = (provider: string) => {
|
const connect = (provider: string) => {
|
||||||
props.onPrepare?.()
|
props.onPrepare?.()
|
||||||
if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
|
props.onSelect(provider)
|
||||||
props.onSelect(provider)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (store.connecting) return
|
|
||||||
setStore("connecting", provider)
|
|
||||||
void serverSDK()
|
|
||||||
.client.provider.auth()
|
|
||||||
.then((response) => {
|
|
||||||
serverSync().set("provider_auth", response.data ?? {})
|
|
||||||
props.onSelect(provider)
|
|
||||||
})
|
|
||||||
.catch(() => props.onSelect(provider))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const move = (event: KeyboardEvent, direction: number) => {
|
const move = (event: KeyboardEvent, direction: number) => {
|
||||||
|
|
@ -395,10 +384,16 @@ function ProviderConnection(props: {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
const params = useParams()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const newLayout = settings.general.newLayoutDesigns
|
const newLayout = settings.general.newLayoutDesigns
|
||||||
const providers = useProviders(props.directory)
|
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 alive = { value: true }
|
||||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||||
|
|
@ -413,38 +408,34 @@ function ProviderConnection(props: {
|
||||||
const provider = createMemo(
|
const provider = createMemo(
|
||||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
() => 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"),
|
label: language.t("provider.connect.method.apiKey"),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
const [auth] = createResource(
|
const [integration] = createResource(
|
||||||
() => props.provider,
|
() => ({ provider: props.provider, directory: directory() }),
|
||||||
async () => {
|
(input) =>
|
||||||
const cached = serverSync().data.provider_auth[props.provider]
|
serverSDK()
|
||||||
if (cached) return cached
|
.api.integration.get({
|
||||||
const res = await serverSDK().client.provider.auth()
|
integrationID: input.provider,
|
||||||
if (!alive.value) return fallback()
|
location: input.directory ? { directory: input.directory } : undefined,
|
||||||
serverSync().set("provider_auth", res.data ?? {})
|
})
|
||||||
return res.data?.[props.provider] ?? fallback()
|
.then((result) => result.data),
|
||||||
},
|
|
||||||
)
|
)
|
||||||
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
const loading = createMemo(() => integration.loading)
|
||||||
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
const methods = createMemo<ConnectMethod[]>(() => {
|
||||||
const cachedMethods = serverSync().data.provider_auth[props.provider]
|
const values = integration.latest?.methods.filter(
|
||||||
const directMethod =
|
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
|
||||||
cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
|
)
|
||||||
|
return values?.length ? values : fallback()
|
||||||
|
})
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: directMethod as undefined | number,
|
methodIndex: undefined as undefined | number,
|
||||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
|
||||||
promptInputs: undefined as undefined | Record<string, string>,
|
promptInputs: undefined as undefined | Record<string, string>,
|
||||||
state: (directMethod === undefined ? "pending" : undefined) as
|
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||||
| undefined
|
|
||||||
| "pending"
|
|
||||||
| "complete"
|
|
||||||
| "error"
|
|
||||||
| "prompt",
|
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -454,7 +445,7 @@ function ProviderConnection(props: {
|
||||||
| { type: "auth.prompt" }
|
| { type: "auth.prompt" }
|
||||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||||
| { type: "auth.pending" }
|
| { type: "auth.pending" }
|
||||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
|
||||||
| { type: "auth.error"; error: string }
|
| { type: "auth.error"; error: string }
|
||||||
|
|
||||||
function dispatch(action: Action) {
|
function dispatch(action: Action) {
|
||||||
|
|
@ -508,7 +499,7 @@ function ProviderConnection(props: {
|
||||||
|
|
||||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||||
if (!value) return ""
|
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 ?? ""
|
return value.label ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -518,7 +509,7 @@ function ProviderConnection(props: {
|
||||||
const hint = suffix?.[1]
|
const hint = suffix?.[1]
|
||||||
return {
|
return {
|
||||||
label: suffix ? label.slice(0, -suffix[0].length) : label,
|
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 +540,22 @@ function ProviderConnection(props: {
|
||||||
const method = methods()[index]
|
const method = methods()[index]
|
||||||
dispatch({ type: "method.select", 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.type === "oauth") {
|
||||||
if (method.prompts?.length && !inputs) {
|
if (method.prompts?.length && !inputs) {
|
||||||
dispatch({ type: "auth.prompt" })
|
dispatch({ type: "auth.prompt" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dispatch({ type: "auth.pending" })
|
dispatch({ type: "auth.pending" })
|
||||||
const start = Date.now()
|
|
||||||
await serverSDK()
|
await serverSDK()
|
||||||
.client.provider.oauth.authorize(
|
.api.integration.oauth.connect({
|
||||||
{
|
integrationID: props.provider,
|
||||||
providerID: props.provider,
|
methodID: method.id,
|
||||||
method: index,
|
inputs: inputs ?? {},
|
||||||
inputs,
|
location: location(),
|
||||||
},
|
})
|
||||||
{ throwOnError: true },
|
|
||||||
)
|
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
const elapsed = Date.now() - start
|
dispatch({ type: "auth.complete", authorization: x.data })
|
||||||
const delay = 1000 - elapsed
|
|
||||||
|
|
||||||
if (delay > 0) {
|
|
||||||
if (timer.current !== undefined) clearTimeout(timer.current)
|
|
||||||
timer.current = setTimeout(() => {
|
|
||||||
timer.current = undefined
|
|
||||||
if (!alive.value) return
|
|
||||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
|
||||||
}, delay)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
|
|
@ -603,9 +570,9 @@ function ProviderConnection(props: {
|
||||||
index: 0,
|
index: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
const prompts = createMemo(() => {
|
||||||
const value = method()
|
const value = method()
|
||||||
return value?.prompts ?? []
|
return value?.type === "oauth" ? (value.prompts ?? []) : []
|
||||||
})
|
})
|
||||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||||
if (!prompt.when) return true
|
if (!prompt.when) return true
|
||||||
|
|
@ -636,10 +603,6 @@ function ProviderConnection(props: {
|
||||||
setFormStore("index", next)
|
setFormStore("index", next)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (method()?.type === "api") {
|
|
||||||
dispatch({ type: "auth.inputs", inputs: value })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await selectMethod(store.methodIndex, value)
|
await selectMethod(store.methodIndex, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -741,7 +704,9 @@ function ProviderConnection(props: {
|
||||||
})
|
})
|
||||||
|
|
||||||
async function complete() {
|
async function complete() {
|
||||||
await serverSDK().client.global.dispose()
|
await serverSync()
|
||||||
|
.refreshProviders()
|
||||||
|
.catch(() => undefined)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
|
|
@ -805,7 +770,7 @@ function ProviderConnection(props: {
|
||||||
listRef = ref
|
listRef = ref
|
||||||
}}
|
}}
|
||||||
items={methods}
|
items={methods}
|
||||||
key={(m) => m?.label}
|
key={(m) => m?.label ?? m?.type}
|
||||||
onSelect={async (selected, index) => {
|
onSelect={async (selected, index) => {
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
void selectMethod(index)
|
void selectMethod(index)
|
||||||
|
|
@ -851,13 +816,10 @@ function ProviderConnection(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
await serverSDK().client.auth.set({
|
await serverSDK().api.integration.connect.key({
|
||||||
providerID: props.provider,
|
integrationID: props.provider,
|
||||||
auth: {
|
location: location(),
|
||||||
type: "api",
|
key: apiKey,
|
||||||
key: apiKey,
|
|
||||||
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
await complete()
|
await complete()
|
||||||
}
|
}
|
||||||
|
|
@ -891,6 +853,7 @@ function ProviderConnection(props: {
|
||||||
ref={apiKey}
|
ref={apiKey}
|
||||||
class="!w-full"
|
class="!w-full"
|
||||||
name="apiKey"
|
name="apiKey"
|
||||||
|
data-input="provider-api-key"
|
||||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||||
value={formStore.value}
|
value={formStore.value}
|
||||||
invalid={formStore.error !== undefined}
|
invalid={formStore.error !== undefined}
|
||||||
|
|
@ -907,7 +870,7 @@ function ProviderConnection(props: {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<ButtonV2 type="submit" variant="contrast">
|
<ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
|
||||||
{language.t("common.continue")}
|
{language.t("common.continue")}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -984,12 +947,13 @@ function ProviderConnection(props: {
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.client.provider.oauth.callback({
|
.api.integration.oauth.complete({
|
||||||
providerID: props.provider,
|
integrationID: props.provider,
|
||||||
method: store.methodIndex,
|
attemptID: store.authorization!.attemptID,
|
||||||
|
location: location(),
|
||||||
code,
|
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 }))
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
await complete()
|
await complete()
|
||||||
|
|
@ -1076,25 +1040,37 @@ function ProviderConnection(props: {
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void (async () => {
|
const poll = async () => {
|
||||||
|
const authorization = store.authorization
|
||||||
|
if (!authorization || !alive.value) return
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.client.provider.oauth.callback({
|
.api.integration.oauth.status({
|
||||||
providerID: props.provider,
|
integrationID: props.provider,
|
||||||
method: store.methodIndex,
|
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 }))
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
|
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const message = formatError(result.error, language.t("common.requestFailed"))
|
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
|
||||||
dispatch({ type: "auth.error", error: message })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (result.status.status === "complete") {
|
||||||
await 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 (
|
return (
|
||||||
|
|
@ -1178,15 +1154,15 @@ function ProviderConnection(props: {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={method()?.type === "api"}>
|
<Match when={method()?.type === "key"}>
|
||||||
<ApiAuthView />
|
<ApiAuthView />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={method()?.type === "oauth"}>
|
<Match when={method()?.type === "oauth"}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={store.authorization?.method === "code"}>
|
<Match when={store.authorization?.mode === "code"}>
|
||||||
<OAuthCodeView />
|
<OAuthCodeView />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.authorization?.method === "auto"}>
|
<Match when={store.authorization?.mode === "auto"}>
|
||||||
<OAuthAutoView />
|
<OAuthAutoView />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const saveMutation = useMutation(() => ({
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||||
|
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
|
||||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
|
|
@ -177,7 +178,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
||||||
<div class="px-2.5 flex gap-4 items-center">
|
<div class="px-2.5 flex gap-4 items-center">
|
||||||
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||||
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,15 +69,11 @@ export const DialogFork: Component = () => {
|
||||||
const dir = base64Encode(sdk().directory)
|
const dir = base64Encode(sdk().directory)
|
||||||
|
|
||||||
sdk()
|
sdk()
|
||||||
.client.session.fork({ sessionID, messageID: item.id })
|
.api.session.fork({ sessionID, messageID: item.id })
|
||||||
.then((forked) => {
|
.then((forked) => {
|
||||||
if (!forked.data) {
|
|
||||||
showToast({ title: language.t("common.requestFailed") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.close()
|
dialog.close()
|
||||||
prompt.set(restored, undefined, { dir, id: forked.data.id })
|
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||||
navigate(`/${dir}/session/${forked.data.id}`)
|
navigate(`/${dir}/session/${forked.id}`)
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import type { Path } from "@opencode-ai/sdk/v2/client"
|
||||||
import {
|
import {
|
||||||
absoluteTreePath,
|
absoluteTreePath,
|
||||||
activeTreeNavigation,
|
activeTreeNavigation,
|
||||||
|
|
@ -28,6 +29,7 @@ import {
|
||||||
} from "./directory-picker-domain"
|
} from "./directory-picker-domain"
|
||||||
import "./dialog-select-directory-v2.css"
|
import "./dialog-select-directory-v2.css"
|
||||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
|
||||||
interface DialogSelectDirectoryV2Props {
|
interface DialogSelectDirectoryV2Props {
|
||||||
title?: string
|
title?: string
|
||||||
|
|
@ -67,11 +69,13 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
() =>
|
async (): Promise<Path | undefined> => {
|
||||||
sdk.client.path
|
if ((await sdk.protocol) !== "v1") return
|
||||||
|
return sdk.client.path
|
||||||
.get()
|
.get()
|
||||||
.then((result) => result.data)
|
.then((result) => result.data)
|
||||||
.catch(() => undefined),
|
.catch(() => undefined)
|
||||||
|
},
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
)
|
)
|
||||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||||
|
|
@ -85,18 +89,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||||
)
|
)
|
||||||
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
||||||
const [suggestions] = createResource(input, async (value) => {
|
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(/\/+$/, "")
|
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 }))
|
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||||
const files = await sdk.client.find
|
const base = pickerRoot(cleaned) || root() || start()
|
||||||
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
|
if (!base) return { query: value, items: directories.slice(0, 5) }
|
||||||
.then((result) => result.data ?? [])
|
const files = await sdk.api.file
|
||||||
|
.find({
|
||||||
|
location: { directory: base },
|
||||||
|
query: pickerFileSearchQuery(base, value, home()),
|
||||||
|
type: "file",
|
||||||
|
limit: 20,
|
||||||
|
})
|
||||||
|
.then((result) => result.data)
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
const results = [
|
const results = [
|
||||||
...directories,
|
...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 {
|
return {
|
||||||
query: value,
|
query: value,
|
||||||
|
|
@ -115,9 +127,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||||
existing ??
|
existing ??
|
||||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||||
return sdk.client.file
|
return sdk.api.file
|
||||||
.list({ directory: absolute, path: "" })
|
.list({ location: { directory: absolute } })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) =>
|
||||||
|
result.data.map((entry) => ({
|
||||||
|
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
|
||||||
|
type: entry.type,
|
||||||
|
})),
|
||||||
|
)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
})
|
})
|
||||||
listings.set(key, request)
|
listings.set(key, request)
|
||||||
|
|
@ -312,6 +329,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||||
{(suggestion, index) => (
|
{(suggestion, index) => (
|
||||||
<button
|
<button
|
||||||
id={`directory-picker-v2-suggestion-${index()}`}
|
id={`directory-picker-v2-suggestion-${index()}`}
|
||||||
|
data-directory-path={suggestion.absolute}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={index() === activeSuggestion()}
|
aria-selected={index() === activeSuggestion()}
|
||||||
data-active={index() === activeSuggestion() ? "" : undefined}
|
data-active={index() === activeSuggestion() ? "" : undefined}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
||||||
|
import type { Path } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
interface DialogSelectDirectoryProps {
|
interface DialogSelectDirectoryProps {
|
||||||
title?: string
|
title?: string
|
||||||
|
|
@ -59,10 +60,11 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
async () => {
|
async (): Promise<Path | undefined> => {
|
||||||
|
if ((await sdk.protocol) !== "v1") return
|
||||||
return sdk.client.path
|
return sdk.client.path
|
||||||
.get()
|
.get()
|
||||||
.then((x) => x.data)
|
.then((result) => result.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
},
|
},
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
|
|
@ -162,7 +164,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
const path = displayPickerPath(item.absolute, filter(), home())
|
const path = displayPickerPath(item.absolute, filter(), home())
|
||||||
if (path === "~") {
|
if (path === "~") {
|
||||||
return (
|
return (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
|
||||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||||
<div class="flex items-center text-14-regular min-w-0">
|
<div class="flex items-center text-14-regular min-w-0">
|
||||||
|
|
@ -174,7 +176,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
|
||||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||||
<div class="flex items-center text-14-regular min-w-0">
|
<div class="flex items-center text-14-regular min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => {
|
||||||
filterKeys={["name", "status"]}
|
filterKeys={["name", "status"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x || toggle.isPending) return
|
if (!x || x.status === "pending" || toggle.isPending) return
|
||||||
toggle.mutate(x.name)
|
toggle.mutate(x.name)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled()}
|
checked={enabled()}
|
||||||
disabled={toggle.isPending && toggle.variables === i.name}
|
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
if (toggle.isPending) return
|
if (toggle.isPending) return
|
||||||
toggle.mutate(i.name)
|
toggle.mutate(i.name)
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
|
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
|
||||||
<div ref={listEl} class="flex min-h-0 flex-col">
|
<div ref={listEl} class="flex min-h-0 flex-col">
|
||||||
<div class="flex w-full flex-col items-start pb-3">
|
<div data-section="free-models" class="flex w-full flex-col items-start pb-3">
|
||||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
|
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
|
||||||
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||||
{language.t("dialog.model.unpaid.freeModels.title")}
|
{language.t("dialog.model.unpaid.freeModels.title")}
|
||||||
|
|
@ -134,6 +134,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||||
{(provider) => (
|
{(provider) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-provider-id={provider.id}
|
||||||
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
|
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
|
||||||
classList={{
|
classList={{
|
||||||
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
|
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,5 @@
|
||||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||||
import {
|
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
|
||||||
Component,
|
|
||||||
ComponentProps,
|
|
||||||
createEffect,
|
|
||||||
createMemo,
|
|
||||||
For,
|
|
||||||
JSX,
|
|
||||||
onCleanup,
|
|
||||||
Show,
|
|
||||||
ValidComponent,
|
|
||||||
} from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
|
@ -29,6 +19,7 @@ import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||||
|
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
|
||||||
import { createEventListener } from "@solid-primitives/event-listener"
|
import { createEventListener } from "@solid-primitives/event-listener"
|
||||||
import { matchesModelSearch } from "./dialog-select-model-search"
|
import { matchesModelSearch } from "./dialog-select-model-search"
|
||||||
|
|
||||||
|
|
@ -122,14 +113,13 @@ const ModelList: Component<{
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
||||||
|
type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
|
||||||
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
||||||
|
|
||||||
export function ModelSelectorPopover(props: {
|
export function ModelSelectorPopover(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
model?: ModelState
|
model?: ModelState
|
||||||
children?: JSX.Element
|
trigger: ModelSelectorTrigger
|
||||||
triggerAs?: ValidComponent
|
|
||||||
triggerProps?: ModelSelectorTriggerProps
|
|
||||||
onClose?: (cause: "escape" | "select") => void
|
onClose?: (cause: "escape" | "select") => void
|
||||||
}) {
|
}) {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
|
|
@ -174,9 +164,7 @@ export function ModelSelectorPopover(props: {
|
||||||
placement="top-start"
|
placement="top-start"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
>
|
>
|
||||||
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
<Kobalte.Trigger as={props.trigger} />
|
||||||
{props.children}
|
|
||||||
</Kobalte.Trigger>
|
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||||
|
|
@ -237,66 +225,101 @@ export function ModelSelectorPopover(props: {
|
||||||
export function ModelSelectorPopoverV2(props: {
|
export function ModelSelectorPopoverV2(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
model?: ModelState
|
model?: ModelState
|
||||||
children?: JSX.Element
|
trigger: ModelSelectorTrigger
|
||||||
triggerAs?: ValidComponent
|
|
||||||
triggerProps?: ModelSelectorTriggerProps
|
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}) {
|
}) {
|
||||||
const model = props.model ?? useLocal().model
|
|
||||||
const language = useLanguage()
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
const controller = createModelSelectorController({
|
||||||
let searchRef: HTMLInputElement | undefined
|
model: props.model,
|
||||||
let contentRef: HTMLDivElement | undefined
|
provider: () => props.provider,
|
||||||
let restoreTrigger = true
|
onSelect: () => props.onClose?.(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModelSelectorPopoverV2View
|
||||||
|
trigger={props.trigger}
|
||||||
|
models={controller.models}
|
||||||
|
groups={controller.groups}
|
||||||
|
current={controller.current}
|
||||||
|
select={controller.select}
|
||||||
|
onManage={() => {
|
||||||
|
void import("./dialog-manage-models").then((module) => {
|
||||||
|
void dialog.show(() => <module.DialogManageModelsV2 />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onClose={() => props.onClose?.()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createModelSelectorController(input: {
|
||||||
|
provider: () => string | undefined
|
||||||
|
model?: ModelState
|
||||||
|
onSelect: () => void
|
||||||
|
}) {
|
||||||
|
const model = input.model ?? useLocal().model
|
||||||
const allModels = createMemo(() =>
|
const allModels = createMemo(() =>
|
||||||
model
|
model
|
||||||
.list()
|
.list()
|
||||||
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
||||||
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
.filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
|
||||||
)
|
)
|
||||||
const models = createMemo(() => {
|
|
||||||
const search = store.search.trim()
|
|
||||||
const filtered = search
|
|
||||||
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
|
||||||
: allModels()
|
|
||||||
|
|
||||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
return {
|
||||||
})
|
models: (search: string) => {
|
||||||
const groups = createMemo(() => {
|
const query = search.trim()
|
||||||
const byProvider = new Map<string, ModelItem[]>()
|
const filtered = query
|
||||||
for (const item of models()) {
|
? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name]))
|
||||||
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
: allModels()
|
||||||
}
|
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
},
|
||||||
})
|
groups: (models: ModelItem[]) => {
|
||||||
const keys = () => [...models().map(modelKey), manageKey]
|
const byProvider = new Map<string, ModelItem[]>()
|
||||||
const current = () => {
|
for (const item of models) {
|
||||||
const value = model.current()
|
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
||||||
return value ? `${value.provider.id}:${value.id}` : undefined
|
}
|
||||||
|
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
||||||
|
},
|
||||||
|
current: () => {
|
||||||
|
const value = model.current()
|
||||||
|
return value ? modelKey(value) : undefined
|
||||||
|
},
|
||||||
|
select: (item: ModelItem) => {
|
||||||
|
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||||
|
input.onSelect()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelSelectorPopoverV2View(props: {
|
||||||
|
trigger: ModelSelectorTrigger
|
||||||
|
models: (search: string) => ModelItem[]
|
||||||
|
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||||
|
current: () => string | undefined
|
||||||
|
select: (item: ModelItem) => void
|
||||||
|
onManage: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||||
|
let searchRef: HTMLInputElement | undefined
|
||||||
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
const dismiss = createMenuDismissController(() => contentRef)
|
||||||
|
|
||||||
|
const models = createMemo(() => props.models(store.search))
|
||||||
|
const groups = createMemo(() => props.groups(models()))
|
||||||
|
const keys = () => [...models().map(modelKey), manageKey]
|
||||||
const initialActive = () => {
|
const initialActive = () => {
|
||||||
const selected = current()
|
const selected = props.current()
|
||||||
const options = keys()
|
const options = keys()
|
||||||
if (selected && options.includes(selected)) return selected
|
if (selected && options.includes(selected)) return selected
|
||||||
return options[0] ?? ""
|
return options[0] ?? ""
|
||||||
}
|
}
|
||||||
const activeItem = () =>
|
const activeItem = () =>
|
||||||
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
||||||
const afterClose = (callback: () => void) => {
|
|
||||||
const complete = () => {
|
|
||||||
if (contentRef?.isConnected) {
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(callback))
|
|
||||||
}
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
}
|
|
||||||
const setOpen = (open: boolean) => {
|
const setOpen = (open: boolean) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
restoreTrigger = true
|
dismiss.allowTriggerRestore()
|
||||||
setStore({ open: true, active: initialActive() })
|
setStore({ open: true, active: initialActive() })
|
||||||
setTimeout(() =>
|
setTimeout(() =>
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|
@ -308,23 +331,15 @@ export function ModelSelectorPopoverV2(props: {
|
||||||
}
|
}
|
||||||
setStore({ open: false, search: "", active: "" })
|
setStore({ open: false, search: "", active: "" })
|
||||||
}
|
}
|
||||||
const select = (item: ModelItem) => {
|
|
||||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
|
||||||
props.onClose?.()
|
|
||||||
}
|
|
||||||
const selectModel = (item: ModelItem) => {
|
const selectModel = (item: ModelItem) => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
afterClose(() => select(item))
|
dismiss.afterClose(() => props.select(item))
|
||||||
}
|
}
|
||||||
const manage = () => {
|
const manage = () => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
afterClose(() => {
|
dismiss.afterClose(props.onManage)
|
||||||
void import("./dialog-manage-models").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogManageModelsV2 />)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
const selectActive = () => {
|
const selectActive = () => {
|
||||||
const item = models().find((item) => modelKey(item) === store.active)
|
const item = models().find((item) => modelKey(item) === store.active)
|
||||||
|
|
@ -343,10 +358,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||||
}
|
}
|
||||||
const setSearch = (value: string) => {
|
const setSearch = (value: string) => {
|
||||||
const search = value.trim()
|
const first = props.models(value)[0]
|
||||||
const first = [...allModels()]
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
|
||||||
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,18 +374,14 @@ export function ModelSelectorPopoverV2(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
||||||
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
<MenuV2.Trigger as={props.trigger} />
|
||||||
{props.children}
|
|
||||||
</MenuV2.Trigger>
|
|
||||||
<MenuV2.Portal>
|
<MenuV2.Portal>
|
||||||
<MenuV2.Content
|
<MenuV2.Content
|
||||||
ref={(el: HTMLDivElement) => (contentRef = el)}
|
ref={(element: HTMLDivElement) => (contentRef = element)}
|
||||||
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||||
onPointerDownOutside={() => (restoreTrigger = false)}
|
onPointerDownOutside={dismiss.preventTriggerRestore}
|
||||||
onFocusOutside={() => (restoreTrigger = false)}
|
onFocusOutside={dismiss.preventTriggerRestore}
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||||
if (!restoreTrigger) event.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col p-0.5">
|
<div class="flex flex-col p-0.5">
|
||||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||||
|
|
@ -393,9 +401,9 @@ export function ModelSelectorPopoverV2(props: {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
afterClose(() => props.onClose?.())
|
dismiss.afterClose(props.onClose)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.altKey || event.metaKey) return
|
if (event.altKey || event.metaKey) return
|
||||||
|
|
@ -445,7 +453,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||||
</MenuV2.GroupLabel>
|
</MenuV2.GroupLabel>
|
||||||
<MenuV2.RadioGroup value={current()}>
|
<MenuV2.RadioGroup value={props.current()}>
|
||||||
<For each={group.items}>
|
<For each={group.items}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<TooltipV2
|
<TooltipV2
|
||||||
|
|
@ -465,7 +473,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||||
<MenuV2.RadioItem
|
<MenuV2.RadioItem
|
||||||
value={modelKey(item)}
|
value={modelKey(item)}
|
||||||
data-option-key={modelKey(item)}
|
data-option-key={modelKey(item)}
|
||||||
data-selected-model={current() === modelKey(item) ? true : undefined}
|
data-selected-model={props.current() === modelKey(item) ? true : undefined}
|
||||||
class="scroll-my-6 w-full"
|
class="scroll-my-6 w-full"
|
||||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
|
import { detectServerProtocol } from "@/utils/server-protocol"
|
||||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
|
|
@ -263,6 +264,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!settings.general.newLayoutDesigns() &&
|
||||||
|
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||||
|
) {
|
||||||
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
resetAdd()
|
resetAdd()
|
||||||
if (options.navigateOnAdd === false) {
|
if (options.navigateOnAdd === false) {
|
||||||
|
|
@ -307,6 +315,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!settings.general.newLayoutDesigns() &&
|
||||||
|
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||||
|
) {
|
||||||
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (normalized === input.original.http.url) {
|
if (normalized === input.original.http.url) {
|
||||||
server.add(conn)
|
server.add(conn)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -344,7 +359,10 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||||
)
|
)
|
||||||
|
|
||||||
const sortedItems = createMemo(() => {
|
const sortedItems = createMemo(() => {
|
||||||
const list = items()
|
const raw = items()
|
||||||
|
const list = settings.general.newLayoutDesigns()
|
||||||
|
? raw
|
||||||
|
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
||||||
if (!list.length) return list
|
if (!list.length) return list
|
||||||
const active = current()
|
const active = current()
|
||||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||||
|
|
|
||||||
|
|
@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => {
|
||||||
test("resolves directory autocomplete from the current browser root", async () => {
|
test("resolves directory autocomplete from the current browser root", async () => {
|
||||||
const directories: string[] = []
|
const directories: string[] = []
|
||||||
const sdk = {
|
const sdk = {
|
||||||
client: {
|
api: {
|
||||||
find: {
|
file: {
|
||||||
files: (input: { directory: string }) => {
|
find: (input: { location?: { directory?: string } }) => {
|
||||||
directories.push(input.directory)
|
directories.push(input.location?.directory ?? "")
|
||||||
return Promise.resolve({ data: [] })
|
return Promise.resolve({ data: [] })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -152,6 +152,29 @@ test("resolves directory autocomplete from the current browser root", async () =
|
||||||
expect(directories).toEqual(["/repo", "/repo/src"])
|
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", () => {
|
test("identifies the next directory level to preload", () => {
|
||||||
expect(
|
expect(
|
||||||
preloadTreeDirectories("src/", [
|
preloadTreeDirectories("src/", [
|
||||||
|
|
|
||||||
|
|
@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||||
let current = 0
|
let current = 0
|
||||||
|
|
||||||
const scoped = (value: string) => {
|
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()
|
const base = args.base()
|
||||||
if (!base) return
|
if (!base) return
|
||||||
const raw = normalizePickerDrive(value)
|
|
||||||
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
||||||
const home = args.home()
|
const home = args.home()
|
||||||
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
||||||
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
|
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 }
|
return { directory: trimPickerPath(base), path: raw }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -342,14 +342,17 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||||
const key = trimPickerPath(directory)
|
const key = trimPickerPath(directory)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const request = args.sdk.client.file
|
const request = args.sdk.api.file
|
||||||
.list({ directory: key, path: "" })
|
.list({ location: { directory: key } })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) => result.data)
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
.then((nodes) =>
|
.then((nodes) =>
|
||||||
nodes
|
nodes
|
||||||
.filter((node) => node.type === "directory")
|
.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)
|
cache.set(key, request)
|
||||||
return request
|
return request
|
||||||
|
|
@ -371,9 +374,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||||
const query = normalizePickerDrive(input.path)
|
const query = normalizePickerDrive(input.path)
|
||||||
if (!pathInput) {
|
if (!pathInput) {
|
||||||
const results = await args.sdk.client.find
|
const results = await args.sdk.api.file
|
||||||
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) => result.data.map((entry) => entry.path))
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
if (!active()) return []
|
if (!active()) return []
|
||||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
|
|
@ -70,13 +71,26 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||||
const start = store.startup.trim()
|
const start = store.startup.trim()
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
if (props.project.id && props.project.id !== "global") {
|
||||||
await serverCtx().sdk.client.project.update({
|
if ((await serverCtx().sdk.protocol) !== "v1") return
|
||||||
projectID: props.project.id,
|
const project = await serverCtx()
|
||||||
directory: props.project.worktree,
|
.sdk.client.project.update({
|
||||||
name,
|
projectID: props.project.id,
|
||||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
directory: props.project.worktree,
|
||||||
commands: { start },
|
name,
|
||||||
})
|
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
|
commands: { start },
|
||||||
|
})
|
||||||
|
.then((result) => result.data)
|
||||||
|
if (!project) return
|
||||||
|
// const project = await serverCtx().sdk.api.project.update({
|
||||||
|
// projectID: props.project.id,
|
||||||
|
// name,
|
||||||
|
// icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
|
// commands: { start },
|
||||||
|
// })
|
||||||
|
serverCtx().sync.set("project", (items) =>
|
||||||
|
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
|
||||||
|
)
|
||||||
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,9 @@ export type PromptInputV2ComposerProps = {
|
||||||
class?: string
|
class?: string
|
||||||
controller: PromptInputV2ComposerController
|
controller: PromptInputV2ComposerController
|
||||||
borderUnderlay?: boolean
|
borderUnderlay?: boolean
|
||||||
edit?: PromptInputProps["edit"]
|
|
||||||
onEditLoaded?: PromptInputProps["onEditLoaded"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
|
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
||||||
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
||||||
readonly model: PromptInputProps["controls"]["model"]
|
readonly model: PromptInputProps["controls"]["model"]
|
||||||
}
|
}
|
||||||
|
|
@ -51,9 +49,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
useCommands(props)
|
|
||||||
useEditHandler(props)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<PromptInputV2
|
<PromptInputV2
|
||||||
|
|
@ -82,70 +77,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const useEditHandler = (props: PromptInputV2ComposerProps) => {
|
|
||||||
const prompt = usePrompt()
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => props.edit?.id,
|
|
||||||
(id) => {
|
|
||||||
const edit = props.edit
|
|
||||||
if (!id || !edit) return
|
|
||||||
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
|
|
||||||
edit.context.forEach((item) =>
|
|
||||||
prompt.context.add({
|
|
||||||
type: item.type,
|
|
||||||
path: item.path,
|
|
||||||
selection: item.selection,
|
|
||||||
comment: item.comment,
|
|
||||||
commentID: item.commentID,
|
|
||||||
commentOrigin: item.commentOrigin,
|
|
||||||
preview: item.preview,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
props.controller.dispatch({ type: "mode.normal" })
|
|
||||||
props.controller.resetHistory()
|
|
||||||
prompt.set(edit.prompt, promptLength(edit.prompt))
|
|
||||||
props.controller.restoreFocus()
|
|
||||||
props.onEditLoaded?.()
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const useCommands = (props: PromptInputV2ComposerProps) => {
|
|
||||||
const command = useCommand()
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
command.register("prompt-input", () => [
|
|
||||||
{
|
|
||||||
id: "file.attach",
|
|
||||||
title: language.t("prompt.action.attachFile"),
|
|
||||||
category: language.t("command.category.file"),
|
|
||||||
keybind: "mod+u",
|
|
||||||
disabled: props.controller.state.mode !== "normal",
|
|
||||||
onSelect: () => props.controller.attach(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "prompt.mode.shell",
|
|
||||||
title: language.t("command.prompt.mode.shell"),
|
|
||||||
category: language.t("command.category.session"),
|
|
||||||
keybind: "mod+shift+x",
|
|
||||||
disabled: props.controller.state.mode === "shell",
|
|
||||||
onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "prompt.mode.normal",
|
|
||||||
title: language.t("command.prompt.mode.normal"),
|
|
||||||
category: language.t("command.category.session"),
|
|
||||||
keybind: "mod+shift+e",
|
|
||||||
disabled: props.controller.state.mode === "normal",
|
|
||||||
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
|
@ -310,7 +241,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||||
)
|
)
|
||||||
const resources = createMemo(() =>
|
const resources = createMemo(() =>
|
||||||
Object.values(sync().data.mcp_resource).map((resource) => ({
|
Object.values(sync().data.mcp_resource).map((resource) => ({
|
||||||
id: `resource:${resource.client}:${resource.uri}`,
|
id: `resource:${resource.server}:${resource.uri}`,
|
||||||
kind: "resource" as const,
|
kind: "resource" as const,
|
||||||
label: `@${resource.name}`,
|
label: `@${resource.name}`,
|
||||||
path: resource.uri,
|
path: resource.uri,
|
||||||
|
|
@ -327,7 +258,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||||
source: {
|
source: {
|
||||||
type: "resource" as const,
|
type: "resource" as const,
|
||||||
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
|
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
|
||||||
clientName: resource.client,
|
clientName: resource.server,
|
||||||
uri: resource.uri,
|
uri: resource.uri,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -447,15 +378,16 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||||
},
|
},
|
||||||
view: {
|
view: {
|
||||||
placeholder: designPlaceholder,
|
placeholder: designPlaceholder,
|
||||||
agent:
|
get agent() {
|
||||||
props.controls.agents.visible && props.controls.agents.options.length > 0
|
return props.controls.agents.visible && props.controls.agents.options.length > 0
|
||||||
? {
|
? {
|
||||||
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
|
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
|
||||||
current: () => props.controls.agents.current,
|
current: () => props.controls.agents.current,
|
||||||
onSelect: props.controls.agents.select,
|
onSelect: (value: string) => props.controls.agents.select(value),
|
||||||
keybind: () => command.keybindParts("agent.cycle"),
|
keybind: () => command.keybindParts("agent.cycle"),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined
|
||||||
|
},
|
||||||
variant: {
|
variant: {
|
||||||
options: () => variants().map((value) => ({ id: value, label: value })),
|
options: () => variants().map((value) => ({ id: value, label: value })),
|
||||||
current: () => props.controls.model.selection.variant.current() ?? "default",
|
current: () => props.controls.model.selection.variant.current() ?? "default",
|
||||||
|
|
@ -471,6 +403,62 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
Object.defineProperty(controller, "model", { get: () => props.controls.model })
|
Object.defineProperty(controller, "model", { get: () => props.controls.model })
|
||||||
|
|
||||||
|
command.register("prompt-input", () => [
|
||||||
|
{
|
||||||
|
id: "file.attach",
|
||||||
|
title: language.t("prompt.action.attachFile"),
|
||||||
|
category: language.t("command.category.file"),
|
||||||
|
keybind: "mod+u",
|
||||||
|
disabled: controller.state.mode !== "normal",
|
||||||
|
onSelect: () => controller.attach(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.shell",
|
||||||
|
title: language.t("command.prompt.mode.shell"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: "mod+shift+x",
|
||||||
|
disabled: controller.state.mode === "shell",
|
||||||
|
onSelect: () => controller.dispatch({ type: "mode.shell" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.normal",
|
||||||
|
title: language.t("command.prompt.mode.normal"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: "mod+shift+e",
|
||||||
|
disabled: controller.state.mode === "normal",
|
||||||
|
onSelect: () => controller.dispatch({ type: "mode.normal" }),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => props.edit?.id,
|
||||||
|
(id) => {
|
||||||
|
const edit = props.edit
|
||||||
|
if (!id || !edit) return
|
||||||
|
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
|
||||||
|
edit.context.forEach((item) =>
|
||||||
|
prompt.context.add({
|
||||||
|
type: item.type,
|
||||||
|
path: item.path,
|
||||||
|
selection: item.selection,
|
||||||
|
comment: item.comment,
|
||||||
|
commentID: item.commentID,
|
||||||
|
commentOrigin: item.commentOrigin,
|
||||||
|
preview: item.preview,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
controller.dispatch({ type: "mode.normal" })
|
||||||
|
controller.resetHistory()
|
||||||
|
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||||
|
controller.restoreFocus()
|
||||||
|
props.onEditLoaded?.()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return controller as PromptInputV2ComposerController
|
return controller as PromptInputV2ComposerController
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -520,6 +508,7 @@ function PromptInputV2ModelControl(props: {
|
||||||
fallback={
|
fallback={
|
||||||
<ButtonV2
|
<ButtonV2
|
||||||
data-action="prompt-model"
|
data-action="prompt-model"
|
||||||
|
data-control-type="dialog"
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
size="normal"
|
size="normal"
|
||||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||||
|
|
@ -533,19 +522,22 @@ function PromptInputV2ModelControl(props: {
|
||||||
>
|
>
|
||||||
<ModelSelectorPopoverV2
|
<ModelSelectorPopoverV2
|
||||||
model={props.model}
|
model={props.model}
|
||||||
triggerAs={ButtonV2}
|
trigger={(triggerProps) => (
|
||||||
triggerProps={{
|
<ButtonV2
|
||||||
variant: "ghost-muted",
|
{...triggerProps}
|
||||||
size: "normal",
|
variant="ghost-muted"
|
||||||
style: { height: "28px" },
|
size="normal"
|
||||||
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
|
style={{ height: "28px" }}
|
||||||
classList: { "animate-in fade-in": shouldAnimate() },
|
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||||
"data-action": "prompt-model",
|
classList={{ "animate-in fade-in": shouldAnimate() }}
|
||||||
}}
|
data-action="prompt-model"
|
||||||
|
data-control-type="popover"
|
||||||
|
>
|
||||||
|
{content()}
|
||||||
|
</ButtonV2>
|
||||||
|
)}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
>
|
/>
|
||||||
{content()}
|
|
||||||
</ModelSelectorPopoverV2>
|
|
||||||
</Show>
|
</Show>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||||
import { createPromptState } from "@/context/prompt"
|
import { createPromptState } from "@/context/prompt"
|
||||||
|
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
|
||||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||||
|
|
||||||
function createPromptInputStoryRuntime() {
|
function createPromptInputStoryRuntime() {
|
||||||
|
|
@ -28,8 +30,16 @@ function PromptInputExample() {
|
||||||
activeTab: undefined as string | undefined,
|
activeTab: undefined as string | undefined,
|
||||||
reviewOpen: false,
|
reviewOpen: false,
|
||||||
})
|
})
|
||||||
|
const storyModel = {
|
||||||
|
id: "claude-3-7-sonnet",
|
||||||
|
name: "Claude 3.7 Sonnet",
|
||||||
|
provider: { id: "anthropic", name: "Anthropic" },
|
||||||
|
}
|
||||||
const model = {
|
const model = {
|
||||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
current: () => storyModel,
|
||||||
|
list: () => [storyModel],
|
||||||
|
visible: () => true,
|
||||||
|
set: () => {},
|
||||||
variant: {
|
variant: {
|
||||||
list: () => ["fast", "thinking"],
|
list: () => ["fast", "thinking"],
|
||||||
current: () => controls.variant,
|
current: () => controls.variant,
|
||||||
|
|
@ -65,7 +75,6 @@ function PromptInputExample() {
|
||||||
open: () => setControls("reviewOpen", true),
|
open: () => setControls("reviewOpen", true),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
newLayoutDesigns: true,
|
|
||||||
}
|
}
|
||||||
const addReviewComment = () => {
|
const addReviewComment = () => {
|
||||||
const comment = controls.comments + 1
|
const comment = controls.comments + 1
|
||||||
|
|
@ -102,6 +111,93 @@ function PromptInputExample() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const todos: Todo[] = [
|
||||||
|
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
|
||||||
|
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
|
||||||
|
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function PromptInputWithOpenDock() {
|
||||||
|
const input = createPromptInputStoryRuntime()
|
||||||
|
const [controls, setControls] = createStore({
|
||||||
|
agent: "build",
|
||||||
|
activeTab: undefined as string | undefined,
|
||||||
|
todoCollapsed: false,
|
||||||
|
})
|
||||||
|
const inputControls = {
|
||||||
|
agents: {
|
||||||
|
available: [],
|
||||||
|
options: ["build"],
|
||||||
|
get current() {
|
||||||
|
return controls.agent
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
visible: true,
|
||||||
|
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
selection: {
|
||||||
|
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||||
|
variant: { list: () => [], current: () => undefined, set: () => {} },
|
||||||
|
},
|
||||||
|
paid: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
id: "story-session",
|
||||||
|
tabs: {
|
||||||
|
active: () => controls.activeTab,
|
||||||
|
all: () => [],
|
||||||
|
open: () => {},
|
||||||
|
setActive: (tab: string) => setControls("activeTab", tab),
|
||||||
|
},
|
||||||
|
reviewPanel: { opened: () => false, open: () => {} },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const state = {
|
||||||
|
blocked: () => false,
|
||||||
|
questionRequest: () => undefined,
|
||||||
|
permissionRequest: () => undefined,
|
||||||
|
permissionResponding: () => false,
|
||||||
|
decide: () => {},
|
||||||
|
todos: () => todos,
|
||||||
|
dock: () => true,
|
||||||
|
closing: () => false,
|
||||||
|
opening: () => false,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<SessionComposerRegion
|
||||||
|
controller={createSessionComposerRegionController({
|
||||||
|
state,
|
||||||
|
sessionKey: () => "story-session",
|
||||||
|
sessionID: () => "story-session",
|
||||||
|
prompt: input.state,
|
||||||
|
ready: () => true,
|
||||||
|
centered: () => false,
|
||||||
|
todo: {
|
||||||
|
collapsed: () => controls.todoCollapsed,
|
||||||
|
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
||||||
|
},
|
||||||
|
followup: () => undefined,
|
||||||
|
revert: () => undefined,
|
||||||
|
onResponseSubmit: () => {},
|
||||||
|
openParent: () => {},
|
||||||
|
setPromptRef: () => {},
|
||||||
|
setDockRef: () => {},
|
||||||
|
})}
|
||||||
|
promptInput={
|
||||||
|
<PromptInput
|
||||||
|
controls={inputControls}
|
||||||
|
{...input}
|
||||||
|
ref={() => {}}
|
||||||
|
newSessionWorktree=""
|
||||||
|
onNewSessionWorktreeReset={() => {}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "App/PromptInput",
|
title: "App/PromptInput",
|
||||||
id: "app-prompt-input",
|
id: "app-prompt-input",
|
||||||
|
|
@ -116,3 +212,12 @@ export const Basic = {
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DockAlreadyOpen = {
|
||||||
|
render: () => (
|
||||||
|
<div class="pt-10">
|
||||||
|
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
|
||||||
|
<PromptInputWithOpenDock />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -591,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
type: "resource",
|
type: "resource",
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
uri: resource.uri,
|
uri: resource.uri,
|
||||||
client: resource.client,
|
client: resource.server,
|
||||||
display: resource.name,
|
display: resource.name,
|
||||||
description: resource.description,
|
description: resource.description,
|
||||||
mime: resource.mimeType,
|
mime: resource.mimeType,
|
||||||
|
|
@ -709,7 +709,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
title: cmd.name,
|
title: cmd.name,
|
||||||
description: cmd.description,
|
description: cmd.description,
|
||||||
type: "custom" as const,
|
type: "custom" as const,
|
||||||
source: cmd.source,
|
// source: cmd.source,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return [...custom, ...builtin]
|
return [...custom, ...builtin]
|
||||||
|
|
@ -1723,29 +1723,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
>
|
>
|
||||||
<ModelSelectorPopover
|
<ModelSelectorPopover
|
||||||
model={props.controls.model.selection}
|
model={props.controls.model.selection}
|
||||||
triggerAs={Button}
|
trigger={(triggerProps) => (
|
||||||
triggerProps={{
|
<Button
|
||||||
variant: "ghost",
|
{...triggerProps}
|
||||||
size: "normal",
|
variant="ghost"
|
||||||
style: control(),
|
size="normal"
|
||||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
style={control()}
|
||||||
"data-action": "prompt-model",
|
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||||
}}
|
data-action="prompt-model"
|
||||||
|
>
|
||||||
|
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||||
|
<ProviderIcon
|
||||||
|
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">
|
||||||
|
{props.controls.model.selection.current()?.name ??
|
||||||
|
language.t("dialog.model.select.title")}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
onClose={restoreFocus}
|
onClose={restoreFocus}
|
||||||
>
|
/>
|
||||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
|
||||||
<ProviderIcon
|
|
||||||
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<span class="truncate">
|
|
||||||
{props.controls.model.selection.current()?.name ??
|
|
||||||
language.t("dialog.model.select.title")}
|
|
||||||
</span>
|
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
|
||||||
</ModelSelectorPopover>
|
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||||
|
|
||||||
const createdClients: string[] = []
|
const createdClients: string[] = []
|
||||||
const createdSessions: 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 enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||||
const optimistic: Array<{
|
const optimistic: Array<{
|
||||||
directory?: string
|
directory?: string
|
||||||
|
|
@ -20,9 +25,14 @@ const optimistic: Array<{
|
||||||
const optimisticSeeded: boolean[] = []
|
const optimisticSeeded: boolean[] = []
|
||||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||||
const sentShell: string[] = []
|
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||||
const syncedDirectories: string[] = []
|
const syncedDirectories: string[] = []
|
||||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: 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 params: { id?: string } = {}
|
||||||
let search: { draftId?: string } = {}
|
let search: { draftId?: string } = {}
|
||||||
|
|
@ -31,7 +41,7 @@ let variant: string | undefined
|
||||||
let permissionServer = "server-a"
|
let permissionServer = "server-a"
|
||||||
let createSessionGate: Promise<void> | undefined
|
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>({
|
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||||
prompt: promptValue,
|
prompt: promptValue,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
|
|
@ -63,23 +73,39 @@ const prompt = {
|
||||||
const clientFor = (directory: string) => {
|
const clientFor = (directory: string) => {
|
||||||
createdClients.push(directory)
|
createdClients.push(directory)
|
||||||
return {
|
return {
|
||||||
session: {
|
api: {
|
||||||
create: async () => {
|
session: {
|
||||||
await createSessionGate
|
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
||||||
createdSessions.push(directory)
|
await createSessionGate
|
||||||
return {
|
const location = input.location?.directory ?? directory
|
||||||
data: {
|
createdSessions.push(location)
|
||||||
|
sessionCreateInputs.push(input)
|
||||||
|
return {
|
||||||
id: `session-${createdSessions.length}`,
|
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}`,
|
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)
|
session: {
|
||||||
return { data: undefined }
|
|
||||||
},
|
|
||||||
prompt: async () => ({ data: undefined }),
|
|
||||||
promptAsync: async () => ({ data: undefined }),
|
|
||||||
command: async () => ({ data: undefined }),
|
command: async () => ({ data: undefined }),
|
||||||
abort: async () => ({ data: undefined }),
|
abort: async () => ({ data: undefined }),
|
||||||
},
|
},
|
||||||
|
|
@ -172,6 +198,7 @@ beforeAll(async () => {
|
||||||
scope: "local",
|
scope: "local",
|
||||||
directory: "/repo/main",
|
directory: "/repo/main",
|
||||||
client: rootClient,
|
client: rootClient,
|
||||||
|
api: rootClient.api,
|
||||||
url: "http://localhost:4096",
|
url: "http://localhost:4096",
|
||||||
createClient(opts: any) {
|
createClient(opts: any) {
|
||||||
return clientFor(opts.directory)
|
return clientFor(opts.directory)
|
||||||
|
|
@ -183,7 +210,7 @@ beforeAll(async () => {
|
||||||
|
|
||||||
mock.module("@/context/sync", () => ({
|
mock.module("@/context/sync", () => ({
|
||||||
useSync: () => () => ({
|
useSync: () => () => ({
|
||||||
data: { command: [] },
|
data: { command: commands },
|
||||||
session: {
|
session: {
|
||||||
optimistic: {
|
optimistic: {
|
||||||
add: (value: {
|
add: (value: {
|
||||||
|
|
@ -210,6 +237,9 @@ beforeAll(async () => {
|
||||||
session: {
|
session: {
|
||||||
remember: () => undefined,
|
remember: () => undefined,
|
||||||
set: () => undefined,
|
set: () => undefined,
|
||||||
|
sync: async () => {
|
||||||
|
serverSessionSyncs++
|
||||||
|
},
|
||||||
},
|
},
|
||||||
child: (directory: string) => {
|
child: (directory: string) => {
|
||||||
syncedDirectories.push(directory)
|
syncedDirectories.push(directory)
|
||||||
|
|
@ -251,11 +281,17 @@ beforeAll(async () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createdClients.length = 0
|
createdClients.length = 0
|
||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
|
sessionCreateInputs.length = 0
|
||||||
enabledAutoAccept.length = 0
|
enabledAutoAccept.length = 0
|
||||||
optimistic.length = 0
|
optimistic.length = 0
|
||||||
optimisticSeeded.length = 0
|
optimisticSeeded.length = 0
|
||||||
promoted.length = 0
|
promoted.length = 0
|
||||||
promotedDrafts.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 = {}
|
params = {}
|
||||||
search = {}
|
search = {}
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
|
|
@ -264,6 +300,7 @@ beforeEach(() => {
|
||||||
variant = undefined
|
variant = undefined
|
||||||
permissionServer = "server-a"
|
permissionServer = "server-a"
|
||||||
createSessionGate = undefined
|
createSessionGate = undefined
|
||||||
|
serverSessionSyncs = 0
|
||||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -297,8 +334,24 @@ describe("prompt submit worktree selection", () => {
|
||||||
|
|
||||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
expect(createdSessions).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(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||||
|
expect(serverSessionSyncs).toBe(0)
|
||||||
expect(promoted).toEqual([
|
expect(promoted).toEqual([
|
||||||
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
||||||
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
||||||
|
|
@ -419,6 +472,7 @@ describe("prompt submit worktree selection", () => {
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
await Bun.sleep(0)
|
||||||
|
|
||||||
expect(optimistic).toHaveLength(1)
|
expect(optimistic).toHaveLength(1)
|
||||||
expect(optimistic[0]).toMatchObject({
|
expect(optimistic[0]).toMatchObject({
|
||||||
|
|
@ -427,6 +481,56 @@ describe("prompt submit worktree selection", () => {
|
||||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
expect(sentPrompts).toEqual(["/repo/main"])
|
||||||
|
expect(promptInputs[0]).toMatchObject({
|
||||||
|
sessionID: "session-1",
|
||||||
|
text: "ls",
|
||||||
|
files: [],
|
||||||
|
agents: [],
|
||||||
|
})
|
||||||
|
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
||||||
|
expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([
|
||||||
|
{ id: expect.stringMatching(/^prt_/), type: "text", text: "ls" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("submits slash commands through the current session API", async () => {
|
||||||
|
params = { id: "session-1" }
|
||||||
|
variant = "high"
|
||||||
|
commands.push({ name: "review" })
|
||||||
|
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
||||||
|
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info: () => ({ id: "session-1" }),
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||||
|
|
||||||
|
expect(sentCommands).toEqual([
|
||||||
|
{
|
||||||
|
sessionID: "session-1",
|
||||||
|
id: expect.stringMatching(/^msg_/),
|
||||||
|
command: "review",
|
||||||
|
arguments: "staged changes",
|
||||||
|
agent: "agent",
|
||||||
|
model: { id: "model", providerID: "provider", variant: "high" },
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(serverSessionSyncs).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses an injected model selection", async () => {
|
test("uses an injected model selection", async () => {
|
||||||
|
|
@ -487,7 +591,8 @@ describe("prompt submit worktree selection", () => {
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
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])
|
expect(optimisticSeeded).toEqual([true])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import { setCursorPosition } from "./editor-dom"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { ScopedKey } from "@/utils/server-scope"
|
import { ScopedKey } from "@/utils/server-scope"
|
||||||
import { createPromptSubmissionState } from "./submission-state"
|
import { createPromptSubmissionState } from "./submission-state"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
|
|
@ -39,7 +41,7 @@ export type FollowupDraft = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type FollowupSendInput = {
|
type FollowupSendInput = {
|
||||||
client: DirectorySDK["client"]
|
api: DirectorySDK["api"]["session"]
|
||||||
serverSync: ServerSync
|
serverSync: ServerSync
|
||||||
sync: DirectorySync
|
sync: DirectorySync
|
||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
|
|
@ -81,19 +83,21 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await input.client.session.command({
|
const messageID = Identifier.ascending("message")
|
||||||
|
await input.api.command({
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
|
id: messageID,
|
||||||
command: cmd,
|
command: cmd,
|
||||||
arguments: tail.join(" "),
|
arguments: tail.join(" "),
|
||||||
agent: input.draft.agent,
|
agent: input.draft.agent,
|
||||||
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
model: {
|
||||||
variant: input.draft.variant,
|
id: input.draft.model.modelID,
|
||||||
parts: images.map((attachment) => ({
|
providerID: input.draft.model.providerID,
|
||||||
id: Identifier.ascending("part"),
|
variant: input.draft.variant,
|
||||||
type: "file" as const,
|
},
|
||||||
mime: attachment.mime,
|
files: images.map((attachment) => ({
|
||||||
url: attachment.dataUrl,
|
uri: attachment.dataUrl,
|
||||||
filename: attachment.filename,
|
name: attachment.filename,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
|
|
@ -152,13 +156,37 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await input.client.session.promptAsync({
|
await input.api.prompt({
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
|
id: messageID,
|
||||||
agent: input.draft.agent,
|
agent: input.draft.agent,
|
||||||
model: input.draft.model,
|
model: input.draft.model,
|
||||||
messageID,
|
|
||||||
parts: requestParts,
|
|
||||||
variant: input.draft.variant,
|
variant: input.draft.variant,
|
||||||
|
legacyParts: requestParts,
|
||||||
|
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||||
|
files: requestParts.flatMap((part) => {
|
||||||
|
if (part.type !== "file") return []
|
||||||
|
const text = part.source?.text
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
uri: part.url,
|
||||||
|
name: part.filename,
|
||||||
|
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
agents: requestParts.flatMap((part) =>
|
||||||
|
part.type === "agent"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: part.name,
|
||||||
|
mention: part.source
|
||||||
|
? { start: part.source.start, end: part.source.end, text: part.source.value }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -210,6 +238,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||||
|
|
||||||
const errorMessage = (err: unknown) => {
|
const errorMessage = (err: unknown) => {
|
||||||
|
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
||||||
if (err && typeof err === "object" && "data" in err) {
|
if (err && typeof err === "object" && "data" in err) {
|
||||||
const data = (err as { data?: { message?: string } }).data
|
const data = (err as { data?: { message?: string } }).data
|
||||||
if (data?.message) return data.message
|
if (data?.message) return data.message
|
||||||
|
|
@ -222,6 +251,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return Promise.resolve()
|
if (!sessionID) return Promise.resolve()
|
||||||
|
|
||||||
|
serverSync().session.set("todo", sessionID, [])
|
||||||
|
|
||||||
input.onAbort?.()
|
input.onAbort?.()
|
||||||
|
|
||||||
const key = pendingKey(sessionID)
|
const key = pendingKey(sessionID)
|
||||||
|
|
@ -233,9 +264,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
return sdk()
|
return sdk()
|
||||||
.client.session.abort({
|
.api.session.interrupt({ sessionID })
|
||||||
sessionID,
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,9 +391,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
|
|
||||||
let session = input.info()
|
let session = input.info()
|
||||||
if (!session && isNewSession) {
|
if (!session && isNewSession) {
|
||||||
const created = await client.session
|
const created = await sdk()
|
||||||
.create()
|
.api.session.create({
|
||||||
.then((x) => x.data ?? undefined)
|
agent: currentAgent.name,
|
||||||
|
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||||
|
location: { directory: sessionDirectory },
|
||||||
|
})
|
||||||
|
.then(normalizeSessionInfo)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||||
|
|
@ -448,12 +481,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
|
|
||||||
if (mode === "shell") {
|
if (mode === "shell") {
|
||||||
clearInput()
|
clearInput()
|
||||||
client.session
|
const eventID = Event.ID.create()
|
||||||
.shell({
|
sdk()
|
||||||
|
.api.session.shell({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
|
id: eventID,
|
||||||
|
command: text,
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
command: text,
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
|
|
@ -471,23 +506,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||||
if (customCommand) {
|
if (customCommand) {
|
||||||
clearInput()
|
clearInput()
|
||||||
client.session
|
const messageID = Identifier.ascending("message")
|
||||||
.command({
|
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||||
|
sdk()
|
||||||
|
.api.session.command({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
|
id: messageID,
|
||||||
command: commandName,
|
command: commandName,
|
||||||
arguments: args.join(" "),
|
arguments: args.join(" "),
|
||||||
agent,
|
agent,
|
||||||
model: `${model.providerID}/${model.modelID}`,
|
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||||
variant,
|
files: images.map((attachment) => ({
|
||||||
parts: images.map((attachment) => ({
|
uri: attachment.dataUrl,
|
||||||
id: Identifier.ascending("part"),
|
name: attachment.filename,
|
||||||
type: "file" as const,
|
|
||||||
mime: attachment.mime,
|
|
||||||
url: attachment.dataUrl,
|
|
||||||
filename: attachment.filename,
|
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||||
|
|
@ -571,7 +606,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendFollowupDraft({
|
void sendFollowupDraft({
|
||||||
client,
|
api: sdk().api.session,
|
||||||
sync: sync(),
|
sync: sync(),
|
||||||
serverSync: serverSync(),
|
serverSync: serverSync(),
|
||||||
draft,
|
draft,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { useLanguage } from "@/context/language"
|
||||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||||
|
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
|
||||||
|
|
||||||
export type PromptProject = {
|
export type PromptProject = {
|
||||||
name?: string
|
name?: string
|
||||||
|
|
@ -197,8 +198,8 @@ export function PromptProjectSelector(props: {
|
||||||
}) {
|
}) {
|
||||||
const [triggerReady, setTriggerReady] = createSignal(false)
|
const [triggerReady, setTriggerReady] = createSignal(false)
|
||||||
let contentRef: HTMLDivElement | undefined
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
const dismiss = createMenuDismissController(() => contentRef)
|
||||||
let triggerFrame: number | undefined
|
let triggerFrame: number | undefined
|
||||||
let restoreTrigger = true
|
|
||||||
|
|
||||||
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
|
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
|
||||||
const setTriggerRef = (element: HTMLButtonElement) => {
|
const setTriggerRef = (element: HTMLButtonElement) => {
|
||||||
|
|
@ -221,25 +222,15 @@ export function PromptProjectSelector(props: {
|
||||||
props.controller.active()
|
props.controller.active()
|
||||||
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
||||||
: undefined
|
: undefined
|
||||||
const afterClose = (callback: () => void) => {
|
|
||||||
const complete = () => {
|
|
||||||
if (contentRef?.isConnected) {
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(callback))
|
|
||||||
}
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
}
|
|
||||||
const selectProject = (project: PromptProject) => {
|
const selectProject = (project: PromptProject) => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
props.controller.setOpen(false)
|
props.controller.setOpen(false)
|
||||||
afterClose(() => props.controller.select(project))
|
dismiss.afterClose(() => props.controller.select(project))
|
||||||
}
|
}
|
||||||
const selectAction = (server?: string) => {
|
const selectAction = (server?: string) => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
props.controller.setOpen(false)
|
props.controller.setOpen(false)
|
||||||
afterClose(() => props.controller.add(server))
|
dismiss.afterClose(() => props.controller.add(server))
|
||||||
}
|
}
|
||||||
const selectActive = () => {
|
const selectActive = () => {
|
||||||
const project = props.controller.activeProject()
|
const project = props.controller.activeProject()
|
||||||
|
|
@ -267,7 +258,7 @@ export function PromptProjectSelector(props: {
|
||||||
)
|
)
|
||||||
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
|
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
|
||||||
.findLast((element) => element.offsetParent !== null)
|
.findLast((element) => element.offsetParent !== null)
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
target?.focus()
|
target?.focus()
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (props.controller.open()) props.controller.setOpen(false)
|
if (props.controller.open()) props.controller.setOpen(false)
|
||||||
|
|
@ -291,7 +282,10 @@ export function PromptProjectSelector(props: {
|
||||||
placement={props.placement ?? "bottom"}
|
placement={props.placement ?? "bottom"}
|
||||||
gutter={4}
|
gutter={4}
|
||||||
modal={false}
|
modal={false}
|
||||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
onOpenChange={(open) => {
|
||||||
|
if (open) dismiss.allowTriggerRestore()
|
||||||
|
props.controller.setOpen(open)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
|
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
|
|
@ -300,11 +294,9 @@ export function PromptProjectSelector(props: {
|
||||||
id="prompt-project-menu"
|
id="prompt-project-menu"
|
||||||
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
|
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
onPointerDownOutside={() => (restoreTrigger = false)}
|
onPointerDownOutside={dismiss.preventTriggerRestore}
|
||||||
onFocusOutside={() => (restoreTrigger = false)}
|
onFocusOutside={dismiss.preventTriggerRestore}
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||||
if (!restoreTrigger) event.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col p-0.5">
|
<div class="flex flex-col p-0.5">
|
||||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||||
|
|
@ -364,41 +356,45 @@ export function PromptProjectSelector(props: {
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show
|
<div class="max-h-[224px] overflow-y-auto">
|
||||||
when={props.controller.servers().length > 1}
|
<Show
|
||||||
fallback={
|
when={props.controller.servers().length > 1}
|
||||||
<DropdownMenu.RadioGroup value={selectedValue()}>
|
fallback={
|
||||||
<For each={props.controller.projects()}>
|
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||||
{(project) => (
|
<For each={props.controller.projects()}>
|
||||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
{(project) => (
|
||||||
)}
|
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||||
</For>
|
)}
|
||||||
</DropdownMenu.RadioGroup>
|
</For>
|
||||||
}
|
</DropdownMenu.RadioGroup>
|
||||||
>
|
}
|
||||||
<For
|
|
||||||
each={props.controller
|
|
||||||
.servers()
|
|
||||||
.filter((server) =>
|
|
||||||
props.controller.projects().some((project) => project.server?.key === server!.key),
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{(server) => (
|
<For
|
||||||
<div>
|
each={props.controller
|
||||||
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
|
.servers()
|
||||||
{server!.name}
|
.filter((server) =>
|
||||||
|
props.controller.projects().some((project) => project.server?.key === server!.key),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(server) => (
|
||||||
|
<div>
|
||||||
|
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
|
||||||
|
{server!.name}
|
||||||
|
</div>
|
||||||
|
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||||
|
<For
|
||||||
|
each={props.controller.projects().filter((project) => project.server?.key === server!.key)}
|
||||||
|
>
|
||||||
|
{(project) => (
|
||||||
|
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</DropdownMenu.RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu.RadioGroup value={selectedValue()}>
|
)}
|
||||||
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
|
</For>
|
||||||
{(project) => (
|
</Show>
|
||||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
</div>
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</DropdownMenu.RadioGroup>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="h-px bg-v2-border-border-muted" />
|
<div class="h-px bg-v2-border-border-muted" />
|
||||||
<div class="flex flex-col p-0.5">
|
<div class="flex flex-col p-0.5">
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,47 @@ export const ServerRowMenu: Component<{
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const key = ServerConnection.key(props.server)
|
const key = ServerConnection.key(props.server)
|
||||||
const builtin = ServerConnection.builtin(props.server)
|
return (
|
||||||
const isDefault = () => props.controller.defaultKey() === key
|
<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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||||
<MenuV2.Trigger
|
<MenuV2.Trigger
|
||||||
|
|
@ -25,31 +63,30 @@ export const ServerRowMenu: Component<{
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconV2 name="outline-dots" />}
|
icon={<IconV2 name="outline-dots" />}
|
||||||
aria-label={language.t("common.moreOptions")}
|
aria-label={props.labels.more}
|
||||||
/>
|
/>
|
||||||
<MenuV2.Portal>
|
<MenuV2.Portal>
|
||||||
<MenuV2.Content>
|
<MenuV2.Content>
|
||||||
<MenuV2.Group>
|
<MenuV2.Group>
|
||||||
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
|
<MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
|
||||||
<MenuV2.Item
|
<MenuV2.Item
|
||||||
disabled={builtin || props.server.type !== "http"}
|
disabled={builtin() || !httpServer()}
|
||||||
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
|
onSelect={() => {
|
||||||
|
const server = httpServer()
|
||||||
|
if (server) props.onEdit(server)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{language.t("dialog.server.menu.edit")}
|
{props.labels.edit}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
<Show when={props.controller.canDefault() && !isDefault()}>
|
<Show when={props.canDefault && !props.isDefault}>
|
||||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
|
||||||
{language.t("dialog.server.menu.default")}
|
|
||||||
</MenuV2.Item>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.controller.canDefault() && isDefault()}>
|
<Show when={props.canDefault && props.isDefault}>
|
||||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
|
||||||
</MenuV2.Item>
|
|
||||||
</Show>
|
</Show>
|
||||||
<MenuV2.Separator />
|
<MenuV2.Separator />
|
||||||
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
|
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
|
||||||
{language.t("dialog.server.menu.delete")}
|
{props.labels.delete}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</MenuV2.Group>
|
</MenuV2.Group>
|
||||||
</MenuV2.Content>
|
</MenuV2.Content>
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,3 @@ export { SortableTab, FileVisual } from "./session-sortable-tab"
|
||||||
export { SortableTabV2 } from "./session-sortable-tab-v2"
|
export { SortableTabV2 } from "./session-sortable-tab-v2"
|
||||||
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
||||||
export { NewSessionView } from "./session-new-view"
|
export { NewSessionView } from "./session-new-view"
|
||||||
export { NewSessionDesignView } from "./session-new-design-view"
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import type { JSX } from "solid-js"
|
|
||||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
|
||||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
|
||||||
|
|
||||||
export function NewSessionDesignView(props: { children: JSX.Element }) {
|
|
||||||
return (
|
|
||||||
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
|
|
||||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
|
||||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
|
||||||
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
|
||||||
<div class="mt-8">{props.children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -127,11 +127,14 @@ export const SettingsGeneral: Component = () => {
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
async () => {
|
||||||
serverSdk()
|
const sdk = serverSdk()
|
||||||
.client.pty.shells()
|
if ((await sdk.protocol) === "v1") {
|
||||||
.then((res) => res.data ?? [])
|
return (await sdk.client.pty.shells()).data ?? []
|
||||||
.catch(() => [] as ShellOption[]),
|
}
|
||||||
|
// return (await sdk.api.pty.shells()).data
|
||||||
|
return [] as ShellOption[]
|
||||||
|
},
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ type KeybindMeta = {
|
||||||
|
|
||||||
type KeybindMap = Record<string, string | undefined>
|
type KeybindMap = Record<string, string | undefined>
|
||||||
type CommandContext = ReturnType<typeof useCommand>
|
type CommandContext = ReturnType<typeof useCommand>
|
||||||
|
type LanguageContext = ReturnType<typeof useLanguage>
|
||||||
|
type SettingsContext = ReturnType<typeof useSettings>
|
||||||
|
|
||||||
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
|
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
|
||||||
|
|
||||||
|
|
@ -122,7 +124,7 @@ function keybinds(value: unknown): KeybindMap {
|
||||||
return value as KeybindMap
|
return value as KeybindMap
|
||||||
}
|
}
|
||||||
|
|
||||||
function listFor(command: CommandContext, map: KeybindMap, palette: string) {
|
function listFor(command: Pick<CommandContext, "catalog" | "options">, map: KeybindMap, palette: string) {
|
||||||
const out = new Map<string, KeybindMeta>()
|
const out = new Map<string, KeybindMeta>()
|
||||||
out.set(PALETTE_ID, { title: palette, group: "General" })
|
out.set(PALETTE_ID, { title: palette, group: "General" })
|
||||||
|
|
||||||
|
|
@ -262,7 +264,274 @@ function useKeyCapture(input: {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createKeybindSettingsController(
|
||||||
|
input: {
|
||||||
|
command: Pick<CommandContext, "catalog" | "options" | "keybinds">
|
||||||
|
settings: {
|
||||||
|
current: { keybinds: unknown }
|
||||||
|
keybinds: Pick<SettingsContext["keybinds"], "get" | "set" | "resetAll">
|
||||||
|
}
|
||||||
|
target?: Document
|
||||||
|
notify?: (toast: { title: string; description: string }) => void
|
||||||
|
},
|
||||||
|
language: Pick<LanguageContext, "locale" | "t"> = useLanguage(),
|
||||||
|
) {
|
||||||
|
const [store, setStore] = createStore({ active: null as string | null })
|
||||||
|
const overrides = createMemo(() => keybinds(input.settings.current.keybinds))
|
||||||
|
const list = createMemo(() => {
|
||||||
|
language.locale()
|
||||||
|
return listFor(input.command, overrides(), language.t("command.palette"))
|
||||||
|
})
|
||||||
|
const grouped = createMemo(() => groupedFor(list()))
|
||||||
|
const title = (id: string) => list().get(id)?.title ?? ""
|
||||||
|
const effective = (id: string) => {
|
||||||
|
if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND
|
||||||
|
|
||||||
|
const custom = input.settings.keybinds.get(id)
|
||||||
|
if (typeof custom === "string") return custom
|
||||||
|
|
||||||
|
const live = input.command.options.find((item) => item.id === id)
|
||||||
|
if (live?.keybind) return live.keybind
|
||||||
|
return input.command.catalog.find((item) => item.id === id)?.keybind
|
||||||
|
}
|
||||||
|
const used = createMemo(() => {
|
||||||
|
const value = new Map<string, { id: string; title: string }[]>()
|
||||||
|
|
||||||
|
for (const id of list().keys()) {
|
||||||
|
for (const signature of signatures(effective(id))) {
|
||||||
|
const items = value.get(signature)
|
||||||
|
if (items) {
|
||||||
|
items.push({ id, title: title(id) })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value.set(signature, [{ id, title: title(id) }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
const stop = () => {
|
||||||
|
if (!store.active) return
|
||||||
|
setStore("active", null)
|
||||||
|
input.command.keybinds(true)
|
||||||
|
}
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
if (store.active === id) {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (store.active) stop()
|
||||||
|
setStore("active", id)
|
||||||
|
input.command.keybinds(false)
|
||||||
|
}
|
||||||
|
const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast))
|
||||||
|
|
||||||
|
const handle = (event: KeyboardEvent) => {
|
||||||
|
const id = store.active
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear =
|
||||||
|
(event.key === "Backspace" || event.key === "Delete") &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.altKey &&
|
||||||
|
!event.shiftKey
|
||||||
|
if (clear) {
|
||||||
|
input.settings.keybinds.set(id, "none")
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = recordKeybind(event)
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
const conflicts = new Map<string, string>()
|
||||||
|
for (const signature of signatures(next)) {
|
||||||
|
for (const item of used().get(signature) ?? []) {
|
||||||
|
if (item.id === id) continue
|
||||||
|
conflicts.set(item.id, item.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflicts.size > 0) {
|
||||||
|
notify({
|
||||||
|
title: language.t("settings.shortcuts.conflict.title"),
|
||||||
|
description: language.t("settings.shortcuts.conflict.description", {
|
||||||
|
keybind: formatKeybind(next, language.t),
|
||||||
|
titles: [...conflicts.values()].join(", "),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input.settings.keybinds.set(id, next)
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = input.target ?? (typeof document === "object" ? document : undefined)
|
||||||
|
if (target) makeEventListener(target, "keydown", handle, { capture: true })
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (store.active) input.command.keybinds(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
catalog: {
|
||||||
|
groups: GROUPS,
|
||||||
|
filtered: (query: string) =>
|
||||||
|
filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", language.t)),
|
||||||
|
title,
|
||||||
|
keybind: (id: string) => formatKeybind(effective(id) ?? "", language.t),
|
||||||
|
},
|
||||||
|
capture: {
|
||||||
|
active: () => store.active,
|
||||||
|
toggle,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"),
|
||||||
|
reset: () => {
|
||||||
|
stop()
|
||||||
|
input.settings.keybinds.resetAll()
|
||||||
|
notify({
|
||||||
|
title: language.t("settings.shortcuts.reset.toast.title"),
|
||||||
|
description: language.t("settings.shortcuts.reset.toast.description"),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsKeybindsV2() {
|
||||||
|
const command = useCommand()
|
||||||
|
const settings = useSettings()
|
||||||
|
const controller = createKeybindSettingsController({
|
||||||
|
command,
|
||||||
|
settings,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsKeybindsV2View
|
||||||
|
groups={controller.catalog.groups}
|
||||||
|
filtered={controller.catalog.filtered}
|
||||||
|
title={controller.catalog.title}
|
||||||
|
keybind={controller.catalog.keybind}
|
||||||
|
active={controller.capture.active}
|
||||||
|
onCapture={controller.capture.toggle}
|
||||||
|
hasOverrides={controller.settings.hasOverrides}
|
||||||
|
onReset={controller.settings.reset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsKeybindsV2View(props: {
|
||||||
|
groups: KeybindGroup[]
|
||||||
|
filtered: (query: string) => Map<KeybindGroup, string[]>
|
||||||
|
title: (id: string) => string
|
||||||
|
keybind: (id: string) => string
|
||||||
|
active: () => string | null
|
||||||
|
onCapture: (id: string) => void
|
||||||
|
hasOverrides: () => boolean
|
||||||
|
onReset: () => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const [store, setStore] = createStore({ filter: "" })
|
||||||
|
const filtered = createMemo(() => props.filtered(store.filter))
|
||||||
|
const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||||
|
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
|
||||||
|
{language.t("settings.shortcuts.reset.button")}
|
||||||
|
</ButtonV2>
|
||||||
|
</div>
|
||||||
|
<div class="settings-v2-tab-search">
|
||||||
|
<TextInputV2
|
||||||
|
type="search"
|
||||||
|
appearance="base"
|
||||||
|
value={store.filter}
|
||||||
|
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||||
|
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
aria-label={language.t("settings.shortcuts.search.placeholder")}
|
||||||
|
/>
|
||||||
|
<Show when={store.filter}>
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
class="settings-v2-tab-search-clear"
|
||||||
|
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||||
|
onClick={() => setStore("filter", "")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-v2-tab-body">
|
||||||
|
<div class="settings-v2-shortcuts flex flex-col gap-8">
|
||||||
|
<For each={props.groups}>
|
||||||
|
{(group) => (
|
||||||
|
<Show when={(filtered().get(group) ?? []).length > 0}>
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<h3 class="settings-v2-section-title">{language.t(groupKey[group])}</h3>
|
||||||
|
<SettingsListV2>
|
||||||
|
<For each={filtered().get(group) ?? []}>
|
||||||
|
{(id) => (
|
||||||
|
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||||
|
<span>{props.title(id)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-keybind-id={id}
|
||||||
|
classList={{
|
||||||
|
"settings-v2-keybind-button": true,
|
||||||
|
"settings-v2-keybind-button--active": props.active() === id,
|
||||||
|
}}
|
||||||
|
onClick={() => props.onCapture(id)}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={props.active() === id}
|
||||||
|
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||||
|
>
|
||||||
|
{language.t("settings.shortcuts.pressKeys")}
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={store.filter && !hasResults()}>
|
||||||
|
<div class="settings-v2-shortcuts-status">
|
||||||
|
<span>{language.t("settings.shortcuts.search.empty")}</span>
|
||||||
|
<span class="settings-v2-shortcuts-status-filter">"{store.filter}"</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||||
|
if (props.v2) return <SettingsKeybindsV2 />
|
||||||
|
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
@ -476,78 +745,37 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
when={props.v2}
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
fallback={
|
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
||||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
|
||||||
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
|
||||||
{language.t("settings.shortcuts.reset.button")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
|
||||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
|
||||||
<TextField
|
|
||||||
variant="ghost"
|
|
||||||
type="text"
|
|
||||||
value={store.filter}
|
|
||||||
onChange={(v) => setStore("filter", v)}
|
|
||||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
<Show when={store.filter}>
|
|
||||||
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{groups}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
|
||||||
<div class="settings-v2-tab-header-row">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
|
||||||
<ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}>
|
|
||||||
{language.t("settings.shortcuts.reset.button")}
|
{language.t("settings.shortcuts.reset.button")}
|
||||||
</ButtonV2>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-v2-tab-search">
|
|
||||||
<TextInputV2
|
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||||
type="search"
|
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||||
appearance="base"
|
<TextField
|
||||||
|
variant="ghost"
|
||||||
|
type="text"
|
||||||
value={store.filter}
|
value={store.filter}
|
||||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
onChange={(v) => setStore("filter", v)}
|
||||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
aria-label={language.t("settings.shortcuts.search.placeholder")}
|
class="flex-1"
|
||||||
/>
|
/>
|
||||||
<Show when={store.filter}>
|
<Show when={store.filter}>
|
||||||
<IconButtonV2
|
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
||||||
type="button"
|
|
||||||
variant="ghost-muted"
|
|
||||||
size="small"
|
|
||||||
class="settings-v2-tab-search-clear"
|
|
||||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
|
||||||
onClick={() => setStore("filter", "")}
|
|
||||||
/>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-v2-tab-body">{groups}</div>
|
</div>
|
||||||
</>
|
{groups}
|
||||||
</Show>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { createMemo, type Component, For, Show } from "solid-js"
|
import { createMemo, type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
||||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||||
|
|
@ -39,8 +39,9 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const providers = useProviders()
|
const providers = useProviders(() => undefined)
|
||||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||||
|
|
||||||
const connect = (provider?: string) => {
|
const connect = (provider?: string) => {
|
||||||
|
|
@ -83,7 +84,8 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||||
return language.t("settings.providers.tag.other")
|
return language.t("settings.providers.tag.other")
|
||||||
}
|
}
|
||||||
|
|
||||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
|
const canDisconnect = (item: ProviderItem) =>
|
||||||
|
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
|
||||||
|
|
||||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||||
|
|
||||||
|
|
@ -96,6 +98,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
const disableProvider = async (providerID: string, name: string) => {
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
|
if (protocol() !== "v1") return
|
||||||
const before = serverSync().data.config.disabled_providers ?? []
|
const before = serverSync().data.config.disabled_providers ?? []
|
||||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
serverSync().set("config", "disabled_providers", next)
|
serverSync().set("config", "disabled_providers", next)
|
||||||
|
|
@ -218,31 +221,33 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<div
|
<Show when={protocol() === "v1"}>
|
||||||
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
|
<div
|
||||||
data-component="custom-provider-section"
|
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="session.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>
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
variant="secondary"
|
|
||||||
icon="plus-small"
|
|
||||||
onClick={() => {
|
|
||||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{language.t("common.connect")}
|
<div class="flex flex-col min-w-0">
|
||||||
</Button>
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
</div>
|
<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>
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="secondary"
|
||||||
|
icon="plus-small"
|
||||||
|
onClick={() => {
|
||||||
|
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{language.t("common.connect")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</SettingsList>
|
</SettingsList>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Component, createSignal, startTransition } from "solid-js"
|
import { Component, createMemo, createSignal, startTransition } from "solid-js"
|
||||||
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
|
@ -11,6 +11,9 @@ import { SettingsModelsV2 } from "./models"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
import { SettingsServersV2 } from "./servers"
|
import { SettingsServersV2 } from "./servers"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
|
||||||
export const DialogSettings: Component<{
|
export const DialogSettings: Component<{
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
|
|
@ -19,7 +22,20 @@ export const DialogSettings: Component<{
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const layout = useLayout()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const serverSync = useServerSync()
|
||||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||||
|
const directory = createMemo(() => {
|
||||||
|
const route = layout.route()
|
||||||
|
if (route.type === "dir-new-sesssion") return route.dir
|
||||||
|
if (route.type === "draft") {
|
||||||
|
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||||
|
return draft?.type === "draft" ? draft.directory : undefined
|
||||||
|
}
|
||||||
|
if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
const showProviders = () => {
|
const showProviders = () => {
|
||||||
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
|
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
|
||||||
|
|
@ -87,7 +103,7 @@ export const DialogSettings: Component<{
|
||||||
<SettingsServersV2 />
|
<SettingsServersV2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||||
<SettingsProvidersV2 onBack={showProviders} />
|
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||||
<SettingsModelsV2 />
|
<SettingsModelsV2 />
|
||||||
|
|
|
||||||
|
|
@ -122,11 +122,14 @@ export const SettingsGeneralV2: Component<{
|
||||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
async () => {
|
||||||
serverSdk()
|
const sdk = serverSdk()
|
||||||
.client.pty.shells()
|
if ((await sdk.protocol) === "v1") {
|
||||||
.then((res) => res.data ?? [])
|
return (await sdk.client.pty.shells()).data ?? []
|
||||||
.catch(() => [] as ShellOption[]),
|
}
|
||||||
|
// return (await sdk.api.pty.shells()).data
|
||||||
|
return [] as ShellOption[]
|
||||||
|
},
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,12 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { type Component, For, Show } from "solid-js"
|
import { type Component, For, Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
@ -19,6 +22,11 @@ const PROVIDER_ICON_SIZE = 16
|
||||||
export const SettingsModelsV2: Component = () => {
|
export const SettingsModelsV2: Component = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
|
const serverSdk = useServerSDK()
|
||||||
|
const [store, setStore] = persisted(
|
||||||
|
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
|
||||||
|
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||||
|
)
|
||||||
|
|
||||||
const list = useFilteredList<ModelItem>({
|
const list = useFilteredList<ModelItem>({
|
||||||
items: (_filter) => models.list(),
|
items: (_filter) => models.list(),
|
||||||
|
|
@ -94,41 +102,82 @@ export const SettingsModelsV2: Component = () => {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={list.grouped.latest}>
|
<For each={list.grouped.latest}>
|
||||||
{(group) => (
|
{(group) => {
|
||||||
<div class="settings-v2-section" data-component="settings-models-provider">
|
const searching = () => list.filter().length > 0
|
||||||
<div class="settings-v2-models-group-header">
|
const expanded = () => searching() || !store.collapsed[group.category]
|
||||||
<ProviderIcon
|
|
||||||
id={group.category}
|
return (
|
||||||
width={PROVIDER_ICON_SIZE}
|
<div
|
||||||
height={PROVIDER_ICON_SIZE}
|
class="settings-v2-section"
|
||||||
class="settings-v2-models-provider-icon shrink-0"
|
data-component="settings-models-provider"
|
||||||
/>
|
data-expanded={expanded() ? "" : undefined}
|
||||||
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
|
>
|
||||||
|
<h3 class="settings-v2-models-group-header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="settings-v2-models-group-trigger"
|
||||||
|
aria-expanded={expanded()}
|
||||||
|
disabled={searching()}
|
||||||
|
onClick={() => setStore("collapsed", group.category, expanded())}
|
||||||
|
>
|
||||||
|
<span class="settings-v2-models-group-chevron">
|
||||||
|
<Show
|
||||||
|
when={expanded()}
|
||||||
|
fallback={
|
||||||
|
<svg width="5" height="6" viewBox="0 0 5 6" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M0.75194 5.31663C0.41861 5.51103 0 5.27063 0 4.88473V0.500754C0 0.114854 0.41861 -0.125577 0.75194 0.0688635L4.5096 2.26084C4.8404 2.45378 4.8404 2.93168 4.5096 3.12462L0.75194 5.31663Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
<span class="settings-v2-models-group-label">
|
||||||
|
<ProviderIcon
|
||||||
|
id={group.category}
|
||||||
|
width={PROVIDER_ICON_SIZE}
|
||||||
|
height={PROVIDER_ICON_SIZE}
|
||||||
|
class="settings-v2-models-provider-icon shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="settings-v2-section-title">{group.items[0].provider.name}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<Show when={expanded()}>
|
||||||
|
<SettingsListV2>
|
||||||
|
<For each={group.items}>
|
||||||
|
{(item) => {
|
||||||
|
const key = { providerID: item.provider.id, modelID: item.id }
|
||||||
|
return (
|
||||||
|
<SettingsRowV2 title={item.name} description="">
|
||||||
|
<div>
|
||||||
|
<Switch
|
||||||
|
checked={models.visible(key)}
|
||||||
|
onChange={(checked) => {
|
||||||
|
models.setVisibility(key, checked)
|
||||||
|
}}
|
||||||
|
hideLabel
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</SettingsListV2>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<SettingsListV2>
|
)
|
||||||
<For each={group.items}>
|
}}
|
||||||
{(item) => {
|
|
||||||
const key = { providerID: item.provider.id, modelID: item.id }
|
|
||||||
return (
|
|
||||||
<SettingsRowV2 title={item.name} description="">
|
|
||||||
<div>
|
|
||||||
<Switch
|
|
||||||
checked={models.visible(key)}
|
|
||||||
onChange={(checked) => {
|
|
||||||
models.setVisibility(key, checked)
|
|
||||||
}}
|
|
||||||
hideLabel
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { createMemo, type Component, For, Show } from "solid-js"
|
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||||
|
|
@ -29,17 +29,21 @@ const PROVIDER_NOTES = [
|
||||||
|
|
||||||
const PROVIDER_ICON_SIZE = 16
|
const PROVIDER_ICON_SIZE = 16
|
||||||
|
|
||||||
export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => {
|
export const SettingsProvidersV2: Component<{
|
||||||
|
directory: Accessor<string | undefined>
|
||||||
|
onBack?: () => void
|
||||||
|
}> = (props) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const providers = useProviders()
|
const providers = useProviders(props.directory)
|
||||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||||
|
|
||||||
const connect = (provider?: string) => {
|
const connect = (provider?: string) => {
|
||||||
providerConnect.select(provider)
|
providerConnect.select(provider)
|
||||||
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
|
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
|
||||||
}
|
}
|
||||||
|
|
||||||
const connected = createMemo(() => {
|
const connected = createMemo(() => {
|
||||||
|
|
@ -77,7 +81,8 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||||
return language.t("settings.providers.tag.other")
|
return language.t("settings.providers.tag.other")
|
||||||
}
|
}
|
||||||
|
|
||||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
|
const canDisconnect = (item: ProviderItem) =>
|
||||||
|
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
|
||||||
|
|
||||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||||
|
|
||||||
|
|
@ -90,6 +95,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||||
}
|
}
|
||||||
|
|
||||||
const disableProvider = async (providerID: string, name: string) => {
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
|
if (protocol() !== "v1") return
|
||||||
const before = serverSync().data.config.disabled_providers ?? []
|
const before = serverSync().data.config.disabled_providers ?? []
|
||||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
serverSync().set("config", "disabled_providers", next)
|
serverSync().set("config", "disabled_providers", next)
|
||||||
|
|
@ -218,33 +224,37 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
<Show when={protocol() === "v1"}>
|
||||||
<div class="settings-v2-provider-lead">
|
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||||
<ProviderIcon
|
<div class="settings-v2-provider-lead">
|
||||||
id="session.synthetic"
|
<ProviderIcon
|
||||||
width={PROVIDER_ICON_SIZE}
|
id="synthetic"
|
||||||
height={PROVIDER_ICON_SIZE}
|
width={PROVIDER_ICON_SIZE}
|
||||||
class="settings-v2-provider-icon shrink-0"
|
height={PROVIDER_ICON_SIZE}
|
||||||
/>
|
class="settings-v2-provider-icon shrink-0"
|
||||||
<div class="settings-v2-provider-copy">
|
/>
|
||||||
<div class="settings-v2-provider-main">
|
<div class="settings-v2-provider-copy">
|
||||||
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
|
<div class="settings-v2-provider-main">
|
||||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
<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>
|
</div>
|
||||||
<p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ButtonV2
|
||||||
|
size="normal"
|
||||||
|
variant="neutral"
|
||||||
|
icon="plus"
|
||||||
|
onClick={() => {
|
||||||
|
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{language.t("common.connect")}
|
||||||
|
</ButtonV2>
|
||||||
</div>
|
</div>
|
||||||
<ButtonV2
|
</Show>
|
||||||
size="normal"
|
|
||||||
variant="neutral"
|
|
||||||
icon="plus"
|
|
||||||
onClick={() => {
|
|
||||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{language.t("common.connect")}
|
|
||||||
</ButtonV2>
|
|
||||||
</div>
|
|
||||||
</SettingsListV2>
|
</SettingsListV2>
|
||||||
|
|
||||||
<button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}>
|
<button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}>
|
||||||
|
|
|
||||||
|
|
@ -373,14 +373,64 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-models {
|
.settings-v2-models {
|
||||||
gap: 24px;
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models .settings-v2-section {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models .settings-v2-section[data-expanded] {
|
||||||
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-models-group-header {
|
.settings-v2-models-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models-group-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 4px 8px 4px 4px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models-group-trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--v2-border-border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.settings-v2-models-group-trigger:not(:disabled):hover {
|
||||||
|
background-color: var(--v2-background-bg-layer-02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models-group-trigger:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models-group-chevron {
|
||||||
|
display: flex;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--v2-icon-icon-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models-group-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-models .settings-v2-section-title {
|
.settings-v2-models .settings-v2-section-title {
|
||||||
|
|
@ -394,6 +444,10 @@
|
||||||
color: var(--v2-icon-icon-base);
|
color: var(--v2-icon-icon-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-v2-models [data-component="settings-v2-list"] {
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
|
.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { type ServerHealth } from "@/utils/server-health"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useMcpToggle } from "@/context/mcp"
|
import { useMcpToggle } from "@/context/mcp"
|
||||||
|
import { useServerProtocol } from "@/context/server-sdk"
|
||||||
|
|
||||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||||
const parts = value.split(file)
|
const parts = value.split(file)
|
||||||
|
|
@ -257,6 +258,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
|
|
||||||
const fail = (err: unknown) => {
|
const fail = (err: unknown) => {
|
||||||
showToast({
|
showToast({
|
||||||
|
|
@ -276,7 +278,12 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||||
dialogDead = true
|
dialogDead = true
|
||||||
dialogRun += 1
|
dialogRun += 1
|
||||||
})
|
})
|
||||||
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
|
const sortedServers = createMemo(() => {
|
||||||
|
const list = settings.general.newLayoutDesigns()
|
||||||
|
? global.servers.list()
|
||||||
|
: global.servers.list().filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
||||||
|
return listServersByHealth(list, server.key, global.servers.health)
|
||||||
|
})
|
||||||
const toggleMcp = useMcpToggle()
|
const toggleMcp = useMcpToggle()
|
||||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||||
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||||
|
|
@ -303,7 +310,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||||
{!settings.general.newLayoutDesigns() && (
|
{!settings.general.newLayoutDesigns() && (
|
||||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||||
{global.servers.list().length > 0 ? `${global.servers.list().length} ` : ""}
|
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
|
||||||
{language.t("status.popover.tab.servers")}
|
{language.t("status.popover.tab.servers")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
)}
|
)}
|
||||||
|
|
@ -315,10 +322,12 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||||
{language.t("status.popover.tab.lsp")}
|
{language.t("status.popover.tab.lsp")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
<Show when={protocol() === "v1"}>
|
||||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||||
{language.t("status.popover.tab.plugins")}
|
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||||
</Tabs.Trigger>
|
{language.t("status.popover.tab.plugins")}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Show>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
{!settings.general.newLayoutDesigns() && (
|
{!settings.general.newLayoutDesigns() && (
|
||||||
|
|
@ -478,25 +487,27 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
<Tabs.Content value="plugins">
|
<Show when={protocol() === "v1"}>
|
||||||
<div class="flex flex-col px-2 pb-2">
|
<Tabs.Content value="plugins">
|
||||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
<div class="flex flex-col px-2 pb-2">
|
||||||
<Show
|
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||||
when={plugins().length > 0}
|
<Show
|
||||||
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
when={plugins().length > 0}
|
||||||
>
|
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
||||||
<For each={plugins()}>
|
>
|
||||||
{(plugin) => (
|
<For each={plugins()}>
|
||||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
{(plugin) => (
|
||||||
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||||
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
||||||
</div>
|
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
||||||
)}
|
</div>
|
||||||
</For>
|
)}
|
||||||
</Show>
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Tabs.Content>
|
||||||
</Tabs.Content>
|
</Show>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
|
import {
|
||||||
|
hasNonBlockingServiceIssue,
|
||||||
|
hasServiceNeedingAttention,
|
||||||
|
serverStatusDotClass,
|
||||||
|
} from "./status-popover-indicator"
|
||||||
|
|
||||||
describe("serverStatusDotClass", () => {
|
describe("serverStatusDotClass", () => {
|
||||||
test("uses the success token while the server and services are healthy", () => {
|
test("uses the success token while the server and services are healthy", () => {
|
||||||
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
|
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("uses the session attention token when a service needs attention", () => {
|
||||||
|
expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe(
|
||||||
|
"bg-v2-background-bg-accent",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("uses the warning token for non-blocking issues while the server is online", () => {
|
test("uses the warning token for non-blocking issues while the server is online", () => {
|
||||||
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
|
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
|
||||||
})
|
})
|
||||||
|
|
@ -26,7 +36,7 @@ describe("hasNonBlockingServiceIssue", () => {
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
|
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
|
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("detects LSP failures that do not block chatting", () => {
|
test("detects LSP failures that do not block chatting", () => {
|
||||||
|
|
@ -34,3 +44,15 @@ describe("hasNonBlockingServiceIssue", () => {
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
|
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("hasServiceNeedingAttention", () => {
|
||||||
|
test("detects MCP states that need user attention", () => {
|
||||||
|
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
|
||||||
|
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ignores states that do not need user attention", () => {
|
||||||
|
expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false)
|
||||||
|
expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,29 @@
|
||||||
import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
|
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { McpServer } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
|
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
|
||||||
|
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
|
||||||
|
}
|
||||||
|
|
||||||
export function hasNonBlockingServiceIssue(input: {
|
export function hasNonBlockingServiceIssue(input: {
|
||||||
mcp: Array<McpStatus["status"]>
|
mcp: Array<McpServer["status"]["status"]>
|
||||||
lsp: Array<LspStatus["status"]>
|
lsp: Array<LspStatus["status"]>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
|
input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") ||
|
||||||
input.lsp.some((status) => status === "error")
|
input.lsp.some((status) => status === "error")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
|
export function serverStatusDotClass(input: {
|
||||||
|
ready: boolean
|
||||||
|
serverHealth: boolean | undefined
|
||||||
|
attention?: boolean
|
||||||
|
issue: boolean
|
||||||
|
}) {
|
||||||
if (input.serverHealth === false) return "bg-icon-critical-base"
|
if (input.serverHealth === false) return "bg-icon-critical-base"
|
||||||
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
|
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
|
||||||
|
if (input.attention) return "bg-v2-background-bg-accent"
|
||||||
if (input.issue) return "bg-icon-warning-base"
|
if (input.issue) return "bg-icon-warning-base"
|
||||||
if (input.serverHealth === true) return "bg-icon-success-base"
|
if (input.serverHealth === true) return "bg-icon-success-base"
|
||||||
return "bg-border-weak-base"
|
return "bg-border-weak-base"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,11 @@ import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
|
import {
|
||||||
|
hasNonBlockingServiceIssue,
|
||||||
|
hasServiceNeedingAttention,
|
||||||
|
serverStatusDotClass,
|
||||||
|
} from "./status-popover-indicator"
|
||||||
|
|
||||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||||
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
|
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
|
||||||
|
|
@ -22,6 +26,11 @@ export function StatusPopover() {
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||||
|
const attention = createMemo(() =>
|
||||||
|
hasServiceNeedingAttention({
|
||||||
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
|
}),
|
||||||
|
)
|
||||||
const issue = createMemo(() =>
|
const issue = createMemo(() =>
|
||||||
hasNonBlockingServiceIssue({
|
hasNonBlockingServiceIssue({
|
||||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
|
|
@ -49,6 +58,7 @@ export function StatusPopover() {
|
||||||
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
|
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
|
||||||
ready: ready(),
|
ready: ready(),
|
||||||
serverHealth: serverHealth(),
|
serverHealth: serverHealth(),
|
||||||
|
attention: attention(),
|
||||||
issue: issue(),
|
issue: issue(),
|
||||||
})}`}
|
})}`}
|
||||||
/>
|
/>
|
||||||
|
|
@ -85,6 +95,11 @@ function DirectoryStatusPopover() {
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
|
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
|
||||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||||
|
const attention = createMemo(() =>
|
||||||
|
hasServiceNeedingAttention({
|
||||||
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
|
}),
|
||||||
|
)
|
||||||
const issue = createMemo(() =>
|
const issue = createMemo(() =>
|
||||||
hasNonBlockingServiceIssue({
|
hasNonBlockingServiceIssue({
|
||||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
|
|
@ -95,6 +110,7 @@ function DirectoryStatusPopover() {
|
||||||
shown: shown(),
|
shown: shown(),
|
||||||
ready: ready(),
|
ready: ready(),
|
||||||
serverHealth: serverHealth(),
|
serverHealth: serverHealth(),
|
||||||
|
attention: attention(),
|
||||||
issue: issue(),
|
issue: issue(),
|
||||||
label: language.t("status.popover.trigger"),
|
label: language.t("status.popover.trigger"),
|
||||||
onOpenChange: setShown,
|
onOpenChange: setShown,
|
||||||
|
|
@ -118,6 +134,7 @@ function ServerStatusPopover() {
|
||||||
shown: shown(),
|
shown: shown(),
|
||||||
ready: serverHealth() !== undefined,
|
ready: serverHealth() !== undefined,
|
||||||
serverHealth: serverHealth(),
|
serverHealth: serverHealth(),
|
||||||
|
attention: false,
|
||||||
issue: false,
|
issue: false,
|
||||||
label: language.t("status.popover.trigger"),
|
label: language.t("status.popover.trigger"),
|
||||||
onOpenChange: setShown,
|
onOpenChange: setShown,
|
||||||
|
|
@ -135,6 +152,7 @@ type StatusPopoverState = {
|
||||||
shown: boolean
|
shown: boolean
|
||||||
ready: boolean
|
ready: boolean
|
||||||
serverHealth: boolean | undefined
|
serverHealth: boolean | undefined
|
||||||
|
attention: boolean
|
||||||
issue: boolean
|
issue: boolean
|
||||||
label: string
|
label: string
|
||||||
onOpenChange: (value: boolean) => void
|
onOpenChange: (value: boolean) => void
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
|
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
|
||||||
const connection = useServerSDK()().server
|
const connection = useServerSDK()().server
|
||||||
const directory = sdk().directory
|
const directory = sdk().directory
|
||||||
const client = sdk().client
|
|
||||||
const url = sdk().url
|
const url = sdk().url
|
||||||
const auth = connection.http
|
const auth = connection.http
|
||||||
const username = auth?.username ?? "opencode"
|
const username = auth?.username ?? "opencode"
|
||||||
|
|
@ -241,10 +240,21 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pushSize = (cols: number, rows: number) => {
|
const pushSize = async (cols: number, rows: number) => {
|
||||||
return client.pty
|
if ((await sdk().protocol) === "v1") {
|
||||||
.update({
|
return sdk()
|
||||||
|
.client.pty.update({
|
||||||
|
ptyID: id,
|
||||||
|
size: { cols, rows },
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
debugTerminal("failed to sync terminal size", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return sdk()
|
||||||
|
.api.pty.update({
|
||||||
ptyID: id,
|
ptyID: id,
|
||||||
|
location: { directory },
|
||||||
size: { cols, rows },
|
size: { cols, rows },
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|
@ -522,34 +532,54 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
local.onConnectError?.(err)
|
local.onConnectError?.(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
const gone = () =>
|
const gone = async () => {
|
||||||
client.pty
|
if ((await sdk().protocol) === "v1") {
|
||||||
.get({ ptyID: id }, { throwOnError: false })
|
return sdk()
|
||||||
.then((result) => result.response.status === 404)
|
.client.pty.get({ ptyID: id }, { throwOnError: false })
|
||||||
|
.then((result) => result.response.status === 404)
|
||||||
|
.catch((err) => {
|
||||||
|
debugTerminal("failed to inspect terminal session", err)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return sdk()
|
||||||
|
.api.pty.get({ ptyID: id, location: { directory } })
|
||||||
|
.then((result) => result.data.status === "exited")
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
|
||||||
debugTerminal("failed to inspect terminal session", err)
|
debugTerminal("failed to inspect terminal session", err)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const connectToken = async () => {
|
const connectToken = async () => {
|
||||||
const result = await client.pty
|
if ((await sdk().protocol) === "v1") {
|
||||||
.connectToken(
|
const result = await sdk()
|
||||||
{ ptyID: id, directory },
|
.client.pty.connectToken(
|
||||||
{
|
{ ptyID: id, directory },
|
||||||
throwOnError: false,
|
{
|
||||||
headers: { "x-opencode-ticket": "1" },
|
throwOnError: false,
|
||||||
},
|
headers: { "x-opencode-ticket": "1" },
|
||||||
)
|
},
|
||||||
.catch((err: unknown) => {
|
)
|
||||||
if (err instanceof Error && err.message.includes("Request is not supported")) return
|
.catch((err: unknown) => {
|
||||||
throw err
|
if (err instanceof Error && err.message.includes("Request is not supported")) return
|
||||||
})
|
throw err
|
||||||
if (!result) return
|
})
|
||||||
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
|
if (!result) return
|
||||||
if (result.response.status === 404 || result.response.status === 405) return
|
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
|
||||||
if (result.response.status === 403)
|
if (result.response.status === 404 || result.response.status === 405) return
|
||||||
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
if (result.response.status === 403)
|
||||||
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
|
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
||||||
|
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
|
||||||
|
}
|
||||||
|
// return sdk()
|
||||||
|
// .api.pty.connectToken({
|
||||||
|
// ptyID: id,
|
||||||
|
// location: { directory },
|
||||||
|
// "x-opencode-ticket": "1",
|
||||||
|
// })
|
||||||
|
// .then((result) => result.data.ticket)
|
||||||
}
|
}
|
||||||
|
|
||||||
const retry = (err: unknown) => {
|
const retry = (err: unknown) => {
|
||||||
|
|
@ -579,11 +609,14 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
fail(err)
|
fail(err)
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
const protocol = await sdk().protocol
|
||||||
|
// if (protocol === "v2" && !ticket) return
|
||||||
if (once.value) return
|
if (once.value) return
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
|
|
||||||
const socket = new WebSocket(
|
const socket = new WebSocket(
|
||||||
terminalWebSocketURL({
|
terminalWebSocketURL({
|
||||||
|
protocol,
|
||||||
url,
|
url,
|
||||||
id,
|
id,
|
||||||
directory,
|
directory,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,6 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
|
||||||
if (typeof ref === "function") ref(element)
|
if (typeof ref === "function") ref(element)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
|
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, pending: boolean) {
|
||||||
return !dragging && !editing && !committing
|
return !dragging && !editing && !pending
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
|
import { createMutation } from "@tanstack/solid-query"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { ServerConnection, serverName } from "@/context/server"
|
import { ServerConnection, serverName } from "@/context/server"
|
||||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||||
import { showToast } from "@/utils/toast"
|
|
||||||
import type { Session } from "@opencode-ai/sdk/v2"
|
import type { Session } from "@opencode-ai/sdk/v2"
|
||||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||||
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
||||||
|
|
@ -23,8 +22,7 @@ export function TabNavItem(props: {
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
session: () => Session | undefined
|
session: () => Session | undefined
|
||||||
fallbackTitle?: string
|
fallbackTitle?: string
|
||||||
onTitleChange?: (title: string) => void
|
onRename: (title: string) => Promise<void>
|
||||||
onTitleChangeFailed?: (title: string) => void
|
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onNavigate: () => void
|
onNavigate: () => void
|
||||||
active?: boolean
|
active?: boolean
|
||||||
|
|
@ -34,13 +32,12 @@ export function TabNavItem(props: {
|
||||||
pressed?: boolean
|
pressed?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
|
||||||
const [editing, setEditing] = createSignal(false)
|
const [editing, setEditing] = createSignal(false)
|
||||||
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
|
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
|
||||||
let tabRoot!: HTMLDivElement
|
let tabRoot!: HTMLDivElement
|
||||||
let titleEl!: HTMLSpanElement
|
let titleEl!: HTMLSpanElement
|
||||||
let committing = false
|
|
||||||
let measureFrame: number | undefined
|
let measureFrame: number | undefined
|
||||||
|
const rename = createMutation(() => ({ mutationFn: props.onRename }))
|
||||||
|
|
||||||
const closeTab = (event: MouseEvent) => {
|
const closeTab = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
@ -116,41 +113,20 @@ export function TabNavItem(props: {
|
||||||
selection?.addRange(range)
|
selection?.addRange(range)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rename = async (title: string) => {
|
|
||||||
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 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeRename = async (save: boolean) => {
|
const closeRename = async (save: boolean) => {
|
||||||
if (committing || !editing()) return
|
if (rename.isPending || !editing()) return
|
||||||
committing = true
|
|
||||||
|
|
||||||
const original = props.session()?.title ?? ""
|
const original = props.session()?.title ?? ""
|
||||||
const next = (titleEl.textContent ?? "").trim()
|
const next = (titleEl.textContent ?? "").trim()
|
||||||
|
|
||||||
titleEl.scrollLeft = 0
|
titleEl.scrollLeft = 0
|
||||||
if (save && next && next !== original) props.onTitleChange?.(next)
|
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
|
|
||||||
if (!save || !next || next === original) {
|
if (!save || !next || next === original) {
|
||||||
committing = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await rename.mutateAsync(next)
|
||||||
await rename(next)
|
|
||||||
} catch (err) {
|
|
||||||
props.onTitleChangeFailed?.(original)
|
|
||||||
showToast({
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
committing = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
@ -164,7 +140,7 @@ export function TabNavItem(props: {
|
||||||
const openRename = (event: MouseEvent) => {
|
const openRename = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!canOpenTabRename(props.dragging, editing(), committing)) return
|
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
|
||||||
const session = props.session()
|
const session = props.session()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
titleEl.textContent = session.title
|
titleEl.textContent = session.title
|
||||||
|
|
@ -219,88 +195,86 @@ export function TabNavItem(props: {
|
||||||
closeTab(event)
|
closeTab(event)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={title() !== undefined}>
|
<a
|
||||||
<a
|
data-slot="tab-link"
|
||||||
data-slot="tab-link"
|
data-titlebar-tab-link
|
||||||
data-titlebar-tab-link
|
href={props.href}
|
||||||
href={props.href}
|
draggable={false}
|
||||||
draggable={false}
|
onDragStart={(event) => {
|
||||||
onDragStart={(event) => {
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||||
|
if (event.button !== 0) return
|
||||||
|
if (editing()) return
|
||||||
|
if (props.suppressNavigation?.()) return
|
||||||
|
props.onNavigate()
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||||
|
if (event.detail > 0) return
|
||||||
|
if (editing()) return
|
||||||
|
if (props.suppressNavigation?.()) return
|
||||||
|
props.onNavigate()
|
||||||
|
}}
|
||||||
|
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||||
|
>
|
||||||
|
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
|
||||||
|
<Show
|
||||||
|
when={props.session()}
|
||||||
|
fallback={
|
||||||
|
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(session) => (
|
||||||
|
<SessionTabAvatar
|
||||||
|
project={project()}
|
||||||
|
directory={session().directory}
|
||||||
|
sessionId={session().id}
|
||||||
|
server={props.server}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
ref={(el) => {
|
||||||
|
titleEl = el
|
||||||
|
titleEl.textContent = title() ?? ""
|
||||||
|
}}
|
||||||
|
data-slot="tab-title"
|
||||||
|
data-titlebar-tab-title
|
||||||
|
class="min-w-0 flex-1 outline-none leading-4"
|
||||||
|
classList={{
|
||||||
|
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||||
|
"select-text": editing(),
|
||||||
|
}}
|
||||||
|
contenteditable={editing() ? true : undefined}
|
||||||
|
onDblClick={openRename}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
void closeRename(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key !== "Escape") return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
titleEl.textContent = props.session()?.title ?? ""
|
||||||
|
void closeRename(false)
|
||||||
|
}}
|
||||||
|
onBlur={() => void closeRename(true)}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (!editing()) return
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
onMouseDown={(event) => {
|
|
||||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
|
||||||
if (event.button !== 0) return
|
|
||||||
if (editing()) return
|
|
||||||
if (props.suppressNavigation?.()) return
|
|
||||||
props.onNavigate()
|
|
||||||
}}
|
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
if (!editing()) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
|
||||||
if (event.detail > 0) return
|
|
||||||
if (editing()) return
|
|
||||||
if (props.suppressNavigation?.()) return
|
|
||||||
props.onNavigate()
|
|
||||||
}}
|
}}
|
||||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
/>
|
||||||
>
|
</a>
|
||||||
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
|
|
||||||
<Show
|
|
||||||
when={props.session()}
|
|
||||||
fallback={
|
|
||||||
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(session) => (
|
|
||||||
<SessionTabAvatar
|
|
||||||
project={project()}
|
|
||||||
directory={session().directory}
|
|
||||||
sessionId={session().id}
|
|
||||||
server={props.server}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
ref={(el) => {
|
|
||||||
titleEl = el
|
|
||||||
titleEl.textContent = title() ?? ""
|
|
||||||
}}
|
|
||||||
data-slot="tab-title"
|
|
||||||
data-titlebar-tab-title
|
|
||||||
class="min-w-0 flex-1 outline-none leading-4"
|
|
||||||
classList={{
|
|
||||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
|
||||||
"select-text": editing(),
|
|
||||||
}}
|
|
||||||
contenteditable={editing() ? true : undefined}
|
|
||||||
onDblClick={openRename}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault()
|
|
||||||
void closeRename(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.key !== "Escape") return
|
|
||||||
event.preventDefault()
|
|
||||||
titleEl.textContent = props.session()?.title ?? ""
|
|
||||||
void closeRename(false)
|
|
||||||
}}
|
|
||||||
onBlur={() => void closeRename(true)}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
if (!editing()) return
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onClick={(event) => {
|
|
||||||
if (!editing()) return
|
|
||||||
event.preventDefault()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
|
|
|
||||||
23
packages/app/src/components/titlebar-tab-order.test.ts
Normal file
23
packages/app/src/components/titlebar-tab-order.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
|
||||||
|
|
||||||
|
describe("adjacentTabKey", () => {
|
||||||
|
test("follows the visible left-to-right order", () => {
|
||||||
|
expect(adjacentTabKey(["c", "a", "b"], "c", 1)).toBe("a")
|
||||||
|
expect(adjacentTabKey(["c", "a", "b"], "a", -1)).toBe("c")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips tabs omitted from the visible order", () => {
|
||||||
|
expect(adjacentTabKey(["a", "c"], "a", 1)).toBe("c")
|
||||||
|
expect(adjacentTabKey(["a", "c"], "c", 1)).toBe("a")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("merges reordered visible tabs around hidden tabs", () => {
|
||||||
|
expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([
|
||||||
|
"c",
|
||||||
|
"hidden",
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
])
|
||||||
|
})
|
||||||
12
packages/app/src/components/titlebar-tab-order.ts
Normal file
12
packages/app/src/components/titlebar-tab-order.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) {
|
||||||
|
if (!current || order.length === 0) return
|
||||||
|
const index = order.indexOf(current)
|
||||||
|
if (index === -1) return
|
||||||
|
return order[(index + offset + order.length) % order.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) {
|
||||||
|
const visible = new Set(current)
|
||||||
|
const reordered = next.values()
|
||||||
|
return all.map((key) => (visible.has(key) ? (reordered.next().value ?? key) : key))
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
|
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||||
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||||
|
|
@ -15,7 +16,10 @@ import { useCommand } from "@/context/command"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { createTabPromptState } from "@/context/prompt"
|
import { createTabPromptState } from "@/context/prompt"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||||
|
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
|
||||||
|
import type { Session } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
function SessionTabSlot(props: {
|
function SessionTabSlot(props: {
|
||||||
tab: SessionTab
|
tab: SessionTab
|
||||||
|
|
@ -23,12 +27,12 @@ function SessionTabSlot(props: {
|
||||||
index: () => number
|
index: () => number
|
||||||
active: () => boolean
|
active: () => boolean
|
||||||
forceTruncate: boolean
|
forceTruncate: boolean
|
||||||
serverCtx: () => ServerCtx | undefined
|
session: () => Session | undefined
|
||||||
|
fallbackTitle?: string
|
||||||
|
onRename: (title: string) => Promise<void>
|
||||||
onNavigate: (element: HTMLDivElement) => void
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const tabs = useTabs()
|
|
||||||
const language = useLanguage()
|
|
||||||
const sortable = useSortable({
|
const sortable = useSortable({
|
||||||
get id() {
|
get id() {
|
||||||
return props.id
|
return props.id
|
||||||
|
|
@ -38,6 +42,47 @@ function SessionTabSlot(props: {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sortable.ref}
|
||||||
|
data-titlebar-tab-slot
|
||||||
|
data-tab-key={props.id}
|
||||||
|
data-active={props.active()}
|
||||||
|
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||||
|
>
|
||||||
|
<TabNavItem
|
||||||
|
ref={(el) => {
|
||||||
|
ref = el
|
||||||
|
}}
|
||||||
|
href={tabHref(props.tab)}
|
||||||
|
server={props.tab.server}
|
||||||
|
session={props.session}
|
||||||
|
fallbackTitle={props.fallbackTitle}
|
||||||
|
onRename={props.onRename}
|
||||||
|
onNavigate={() => props.onNavigate(ref)}
|
||||||
|
onClose={props.onClose}
|
||||||
|
active={props.active()}
|
||||||
|
forceTruncate={props.forceTruncate}
|
||||||
|
dragging={sortable.isDragSource()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionTabEntry(props: {
|
||||||
|
tab: SessionTab
|
||||||
|
id: string
|
||||||
|
index: () => number
|
||||||
|
active: () => boolean
|
||||||
|
forceTruncate: boolean
|
||||||
|
serverCtx: () => ServerCtx | undefined
|
||||||
|
onVisibleChange: (visible: boolean) => void
|
||||||
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const tabs = useTabs()
|
||||||
|
const language = useLanguage()
|
||||||
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
||||||
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
||||||
const persisted = createMemo(() => tabs.info[props.id])
|
const persisted = createMemo(() => tabs.info[props.id])
|
||||||
|
|
@ -50,8 +95,30 @@ function SessionTabSlot(props: {
|
||||||
)
|
)
|
||||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||||
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
|
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
|
||||||
|
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
|
||||||
let prefetched = false
|
let prefetched = false
|
||||||
|
|
||||||
|
const rename = async (title: string) => {
|
||||||
|
const value = session()
|
||||||
|
const ctx = props.serverCtx()
|
||||||
|
if (!value || !ctx) return
|
||||||
|
|
||||||
|
ctx.sync.session.remember({ ...value, title })
|
||||||
|
try {
|
||||||
|
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
||||||
|
} catch (err) {
|
||||||
|
const current = session()
|
||||||
|
const currentCtx = props.serverCtx()
|
||||||
|
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
|
||||||
|
showToast({
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => props.onVisibleChange(visible()))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const ctx = props.serverCtx()
|
const ctx = props.serverCtx()
|
||||||
const value = session()
|
const value = session()
|
||||||
|
|
@ -83,39 +150,20 @@ function SessionTabSlot(props: {
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Show when={visible()}>
|
||||||
ref={sortable.ref}
|
<SessionTabSlot
|
||||||
data-titlebar-tab-slot
|
tab={props.tab}
|
||||||
data-tab-key={props.id}
|
id={props.id}
|
||||||
data-active={props.active()}
|
index={props.index}
|
||||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
active={props.active}
|
||||||
classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
|
forceTruncate={props.forceTruncate}
|
||||||
>
|
|
||||||
<TabNavItem
|
|
||||||
ref={(el) => {
|
|
||||||
ref = el
|
|
||||||
}}
|
|
||||||
href={tabHref(props.tab)}
|
|
||||||
server={props.tab.server}
|
|
||||||
session={session}
|
session={session}
|
||||||
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
|
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
|
||||||
onTitleChange={(title) => {
|
onRename={rename}
|
||||||
const value = session()
|
onNavigate={props.onNavigate}
|
||||||
const ctx = props.serverCtx()
|
|
||||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
|
||||||
}}
|
|
||||||
onTitleChangeFailed={(title) => {
|
|
||||||
const value = session()
|
|
||||||
const ctx = props.serverCtx()
|
|
||||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
|
||||||
}}
|
|
||||||
onNavigate={() => props.onNavigate(ref)}
|
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
active={props.active()}
|
|
||||||
forceTruncate={props.forceTruncate}
|
|
||||||
dragging={sortable.isDragSource()}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,11 +220,39 @@ export function TitlebarTabStrip(props: {
|
||||||
}) {
|
}) {
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const command = useCommand()
|
||||||
let scrollRef!: HTMLDivElement
|
let scrollRef!: HTMLDivElement
|
||||||
let listRef!: HTMLDivElement
|
let listRef!: HTMLDivElement
|
||||||
let resizeFrame: number | undefined
|
let resizeFrame: number | undefined
|
||||||
|
const [visibility, setVisibility] = createStore<Record<string, boolean>>({})
|
||||||
|
const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)]))
|
||||||
|
const visibleTabIds = () => visibleTabs().map(tabKey)
|
||||||
|
|
||||||
const tabIds = () => props.tabs.map(tabKey)
|
command.register("titlebar-tab-cycle", () => [
|
||||||
|
{
|
||||||
|
id: `tab.prev`,
|
||||||
|
category: "tab",
|
||||||
|
title: "",
|
||||||
|
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
|
||||||
|
hidden: true,
|
||||||
|
onSelect: () => selectAdjacentTab(-1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `tab.next`,
|
||||||
|
category: "tab",
|
||||||
|
title: "",
|
||||||
|
keybind: `mod+option+ArrowRight,ctrl+tab`,
|
||||||
|
hidden: true,
|
||||||
|
onSelect: () => selectAdjacentTab(1),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
function selectAdjacentTab(offset: -1 | 1) {
|
||||||
|
const current = props.currentTab()
|
||||||
|
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
|
||||||
|
const next = props.tabs.find((tab) => tabKey(tab) === key)
|
||||||
|
if (next) props.onNavigate(next)
|
||||||
|
}
|
||||||
|
|
||||||
function refreshOverflow() {
|
function refreshOverflow() {
|
||||||
if (!scrollRef) return
|
if (!scrollRef) return
|
||||||
|
|
@ -204,7 +280,7 @@ export function TitlebarTabStrip(props: {
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
props.tabs.length
|
props.tabs.length
|
||||||
tabIds()
|
visibleTabIds()
|
||||||
refreshOverflow()
|
refreshOverflow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -240,22 +316,29 @@ export function TitlebarTabStrip(props: {
|
||||||
props.onNavigate(tab, tabEl ?? undefined)
|
props.onNavigate(tab, tabEl ?? undefined)
|
||||||
}}
|
}}
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
const current = tabIds()
|
const current = visibleTabIds()
|
||||||
const source = event.operation.source
|
const source = event.operation.source
|
||||||
if (event.canceled || !isSortable(source)) return
|
if (event.canceled || !isSortable(source)) return
|
||||||
|
|
||||||
const { initialIndex, index } = source
|
const { initialIndex, index } = source
|
||||||
if (initialIndex !== index) {
|
if (initialIndex !== index) {
|
||||||
props.onReorder(arrayMove(current, source.initialIndex, source.index))
|
props.onReorder(
|
||||||
|
mergeVisibleTabOrder(
|
||||||
|
props.tabs.map(tabKey),
|
||||||
|
current,
|
||||||
|
arrayMove(current, source.initialIndex, source.index),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
|
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
|
||||||
<For each={props.tabs}>
|
<For each={props.tabs}>
|
||||||
{(tab, index) => {
|
{(tab) => {
|
||||||
const id = tabKey(tab)
|
const id = tabKey(tab)
|
||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
useTabShortcut(index, () => props.onNavigate(tab, ref))
|
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
|
||||||
|
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
|
||||||
const serverCtx = createMemo(() => {
|
const serverCtx = createMemo(() => {
|
||||||
if (tab.type !== "session") return
|
if (tab.type !== "session") return
|
||||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||||
|
|
@ -264,13 +347,14 @@ export function TitlebarTabStrip(props: {
|
||||||
|
|
||||||
if (tab.type === "session") {
|
if (tab.type === "session") {
|
||||||
return (
|
return (
|
||||||
<SessionTabSlot
|
<SessionTabEntry
|
||||||
tab={tab}
|
tab={tab}
|
||||||
id={id}
|
id={id}
|
||||||
index={index}
|
index={visibleIndex}
|
||||||
active={() => props.currentTab() === tab}
|
active={() => props.currentTab() === tab}
|
||||||
forceTruncate={props.forceTruncate}
|
forceTruncate={props.forceTruncate}
|
||||||
serverCtx={serverCtx}
|
serverCtx={serverCtx}
|
||||||
|
onVisibleChange={(visible) => setVisibility(id, visible)}
|
||||||
onNavigate={(element) => {
|
onNavigate={(element) => {
|
||||||
ref = element
|
ref = element
|
||||||
props.onNavigate(tab, element)
|
props.onNavigate(tab, element)
|
||||||
|
|
@ -284,7 +368,7 @@ export function TitlebarTabStrip(props: {
|
||||||
<DraftTabSlot
|
<DraftTabSlot
|
||||||
tab={tab}
|
tab={tab}
|
||||||
id={id}
|
id={id}
|
||||||
index={index}
|
index={visibleIndex}
|
||||||
active={() => props.currentTab() === tab}
|
active={() => props.currentTab() === tab}
|
||||||
title={language.t("command.session.new")}
|
title={language.t("command.session.new")}
|
||||||
onNavigate={(element) => {
|
onNavigate={(element) => {
|
||||||
|
|
@ -318,7 +402,7 @@ function useTabShortcut(index: () => number, onSelect: () => void) {
|
||||||
|
|
||||||
command.register(() => {
|
command.register(() => {
|
||||||
const number = index() + 1
|
const number = index() + 1
|
||||||
if (number > 9) return []
|
if (number < 1 || number > 9) return []
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: `tab.${number}`,
|
id: `tab.${number}`,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
|
|
@ -28,32 +27,13 @@ import { tabKey, useTabs } from "@/context/tabs"
|
||||||
import type { PromptSession } from "@/context/prompt"
|
import type { PromptSession } from "@/context/prompt"
|
||||||
import "./titlebar.css"
|
import "./titlebar.css"
|
||||||
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
|
||||||
type TauriDesktopWindow = {
|
|
||||||
startDragging?: () => Promise<void>
|
|
||||||
toggleMaximize?: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
type TauriThemeWindow = {
|
|
||||||
setTheme?: (theme?: "light" | "dark" | null) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
type TauriApi = {
|
|
||||||
window?: {
|
|
||||||
getCurrentWindow?: () => TauriDesktopWindow
|
|
||||||
}
|
|
||||||
webviewWindow?: {
|
|
||||||
getCurrentWebviewWindow?: () => TauriThemeWindow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
|
|
||||||
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
|
|
||||||
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
|
|
||||||
const legacyTitlebarHeight = 40
|
const legacyTitlebarHeight = 40
|
||||||
const v2TitlebarHeight = 36
|
const v2TitlebarHeight = 36
|
||||||
const minTitlebarZoom = 0.25
|
const minTitlebarZoom = 0.25
|
||||||
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
||||||
|
const macTrafficLightsBaseWidth = 84
|
||||||
|
|
||||||
export type TitlebarUpdate = {
|
export type TitlebarUpdate = {
|
||||||
version: () => string | undefined
|
version: () => string | undefined
|
||||||
|
|
@ -73,7 +53,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const theme = useTheme()
|
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
@ -84,9 +63,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||||
|
|
||||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||||
const electronWindows = createMemo(() => windows() && !tauriApi())
|
|
||||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||||
const web = createMemo(() => platform.platform === "web")
|
const web = createMemo(() => platform.platform === "web")
|
||||||
|
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
|
||||||
const zoom = () => platform.webviewZoom?.() ?? 1
|
const zoom = () => platform.webviewZoom?.() ?? 1
|
||||||
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
||||||
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
||||||
|
|
@ -175,56 +154,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const getWin = () => {
|
|
||||||
if (platform.platform !== "desktop") return
|
|
||||||
return currentDesktopWindow()
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (platform.platform !== "desktop") return
|
|
||||||
|
|
||||||
const scheme = theme.colorScheme()
|
|
||||||
const value = scheme === "system" ? null : scheme
|
|
||||||
|
|
||||||
const win = currentThemeWindow()
|
|
||||||
if (!win?.setTheme) return
|
|
||||||
|
|
||||||
void win.setTheme(value).catch(() => undefined)
|
|
||||||
})
|
|
||||||
|
|
||||||
const interactive = (target: EventTarget | null) => {
|
|
||||||
if (!(target instanceof Element)) return false
|
|
||||||
|
|
||||||
const selector =
|
|
||||||
"button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']"
|
|
||||||
|
|
||||||
return !!target.closest(selector)
|
|
||||||
}
|
|
||||||
|
|
||||||
const drag = (e: MouseEvent) => {
|
|
||||||
if (platform.platform !== "desktop") return
|
|
||||||
if (e.buttons !== 1) return
|
|
||||||
if (interactive(e.target)) return
|
|
||||||
|
|
||||||
const win = getWin()
|
|
||||||
if (!win?.startDragging) return
|
|
||||||
|
|
||||||
e.preventDefault()
|
|
||||||
void win.startDragging().catch(() => undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
const maximize = (e: MouseEvent) => {
|
|
||||||
if (platform.platform !== "desktop") return
|
|
||||||
if (interactive(e.target)) return
|
|
||||||
if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return
|
|
||||||
|
|
||||||
const win = getWin()
|
|
||||||
if (!win?.toggleMaximize) return
|
|
||||||
|
|
||||||
e.preventDefault()
|
|
||||||
void win.toggleMaximize().catch(() => undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
data-slot={useV2Titlebar() ? "titlebar-v2" : undefined}
|
data-slot={useV2Titlebar() ? "titlebar-v2" : undefined}
|
||||||
|
|
@ -237,16 +166,12 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||||
style={{
|
style={{
|
||||||
"min-height": minHeight(),
|
"min-height": minHeight(),
|
||||||
// Keep native macOS traffic lights clear even when the desktop window is narrow.
|
// Keep native macOS traffic lights clear even when the desktop window is narrow.
|
||||||
"padding-left": mac() ? `${84 / zoom()}px` : 0,
|
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
|
||||||
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||||
"max-width": electronWindows()
|
"max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||||
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
|
"align-self": windows() ? "flex-start" : undefined,
|
||||||
: undefined,
|
|
||||||
"align-self": electronWindows() ? "flex-start" : undefined,
|
|
||||||
}}
|
}}
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
onMouseDown={drag}
|
|
||||||
onDblClick={maximize}
|
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={useV2Titlebar()}>
|
<Match when={useV2Titlebar()}>
|
||||||
|
|
@ -267,9 +192,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||||
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
||||||
},
|
},
|
||||||
({ route, sdk }) =>
|
({ route, sdk }) =>
|
||||||
sdk.client.session
|
sdk.api.session
|
||||||
.get({ sessionID: route.sessionId })
|
.get({ sessionID: route.sessionId })
|
||||||
.then((x) => x.data)
|
.then(normalizeSessionInfo)
|
||||||
.catch(() => {}),
|
.catch(() => {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -413,40 +338,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||||
keybind: "mod+shift+t",
|
keybind: "mod+shift+t",
|
||||||
onSelect: () => tabsStoreActions.reopenClosedTab(),
|
onSelect: () => tabsStoreActions.reopenClosedTab(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: `tab.prev`,
|
|
||||||
category: "tab",
|
|
||||||
title: "",
|
|
||||||
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
|
|
||||||
hidden: true,
|
|
||||||
onSelect: () => {
|
|
||||||
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
|
||||||
if (index === -1) return
|
|
||||||
|
|
||||||
index -= 1
|
|
||||||
if (index === -1) index = tabsStore.length - 1
|
|
||||||
|
|
||||||
const next = tabsStore[index]
|
|
||||||
if (next) tabs.select(next)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: `tab.next`,
|
|
||||||
category: "tab",
|
|
||||||
title: "",
|
|
||||||
keybind: `mod+option+ArrowRight,ctrl+tab`,
|
|
||||||
hidden: true,
|
|
||||||
onSelect: () => {
|
|
||||||
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
|
||||||
if (index === -1) return
|
|
||||||
|
|
||||||
index += 1
|
|
||||||
if (index === tabsStore.length) index = 0
|
|
||||||
|
|
||||||
const next = tabsStore[index]
|
|
||||||
if (next) tabs.select(next)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
].filter((v) => v !== undefined)
|
].filter((v) => v !== undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -458,8 +349,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||||
classList={{
|
classList={{
|
||||||
"pt-2": !bottom(),
|
"pt-2": !bottom(),
|
||||||
"pb-2": bottom(),
|
"pb-2": bottom(),
|
||||||
"md:pl-2": mac(),
|
"md:pl-2": macTrafficLights(),
|
||||||
"md:pl-4": !mac(),
|
"md:pl-4": !macTrafficLights(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChannelIndicator debugTools={props.debugTools} />
|
<ChannelIndicator debugTools={props.debugTools} />
|
||||||
|
|
@ -527,9 +418,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
<TitlebarV2Right state={v2RightState()} />
|
<TitlebarV2Right state={v2RightState()} />
|
||||||
<Show when={windows() && !electronWindows()}>
|
|
||||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|
@ -542,14 +430,13 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"flex items-center min-w-0": true,
|
"flex items-center min-w-0": true,
|
||||||
"pl-2": !mac(),
|
"pl-2": !macTrafficLights(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={windows() || linux()}>
|
<Show when={windows() || linux()}>
|
||||||
<WindowsAppMenu command={command} platform={platform} />
|
<WindowsAppMenu command={command} platform={platform} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={mac()}>
|
<Show when={mac()}>
|
||||||
{/*<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />*/}
|
|
||||||
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="menu"
|
icon="menu"
|
||||||
|
|
@ -679,12 +566,10 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||||
"pr-2": !windows(),
|
"pr-2": !windows(),
|
||||||
}}
|
}}
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
onMouseDown={drag}
|
|
||||||
>
|
>
|
||||||
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
||||||
<Show when={windows()}>
|
<Show when={windows()}>
|
||||||
{!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />}
|
<div class="shrink-0" style={{ width: windowsControlsWidth() }} />
|
||||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { commandPaletteOptions, resolveKeybindOption, upsertCommandRegistration, type CommandOption } from "./command"
|
import {
|
||||||
|
activeCommandRegistrations,
|
||||||
|
addCommandRegistration,
|
||||||
|
commandPaletteOptions,
|
||||||
|
resolveKeybindOption,
|
||||||
|
type CommandOption,
|
||||||
|
} from "./command"
|
||||||
|
|
||||||
const paletteOptions: CommandOption[] = [
|
const paletteOptions: CommandOption[] = [
|
||||||
{ id: "settings.open", title: "Open settings" },
|
{ id: "settings.open", title: "Open settings" },
|
||||||
|
|
@ -15,22 +21,31 @@ describe("commandPaletteOptions", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("upsertCommandRegistration", () => {
|
describe("command registrations", () => {
|
||||||
test("replaces keyed registrations", () => {
|
test("shadows keyed registrations while retaining the previous owner", () => {
|
||||||
const one = () => [{ id: "one", title: "One" }]
|
const one = () => [{ id: "one", title: "One" }]
|
||||||
const two = () => [{ id: "two", title: "Two" }]
|
const two = () => [{ id: "two", title: "Two" }]
|
||||||
|
|
||||||
const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two })
|
const registrations = addCommandRegistration([{ key: "layout", options: one }], {
|
||||||
|
key: "layout",
|
||||||
|
options: two,
|
||||||
|
})
|
||||||
|
const active = activeCommandRegistrations(registrations)
|
||||||
|
|
||||||
expect(next).toHaveLength(1)
|
expect(registrations).toHaveLength(2)
|
||||||
expect(next[0]?.options).toBe(two)
|
expect(active).toHaveLength(1)
|
||||||
|
expect(active[0]?.options).toBe(two)
|
||||||
|
|
||||||
|
const restored = activeCommandRegistrations(registrations.filter((entry) => entry.options !== two))
|
||||||
|
expect(restored).toHaveLength(1)
|
||||||
|
expect(restored[0]?.options).toBe(one)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps unkeyed registrations additive", () => {
|
test("keeps unkeyed registrations additive", () => {
|
||||||
const one = () => [{ id: "one", title: "One" }]
|
const one = () => [{ id: "one", title: "One" }]
|
||||||
const two = () => [{ id: "two", title: "Two" }]
|
const two = () => [{ id: "two", title: "Two" }]
|
||||||
|
|
||||||
const next = upsertCommandRegistration([{ options: one }], { options: two })
|
const next = activeCommandRegistrations(addCommandRegistration([{ options: one }], { options: two }))
|
||||||
|
|
||||||
expect(next).toHaveLength(2)
|
expect(next).toHaveLength(2)
|
||||||
expect(next[0]?.options).toBe(two)
|
expect(next[0]?.options).toBe(two)
|
||||||
|
|
|
||||||
|
|
@ -114,9 +114,18 @@ export type CommandRegistration = {
|
||||||
options: Accessor<CommandOption[]>
|
options: Accessor<CommandOption[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
|
export function addCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
|
||||||
if (entry.key === undefined) return [entry, ...registrations]
|
return [entry, ...registrations]
|
||||||
return [entry, ...registrations.filter((x) => x.key !== entry.key)]
|
}
|
||||||
|
|
||||||
|
export function activeCommandRegistrations(registrations: CommandRegistration[]) {
|
||||||
|
const keys = new Set<string>()
|
||||||
|
return registrations.filter((entry) => {
|
||||||
|
if (entry.key === undefined) return true
|
||||||
|
if (keys.has(entry.key)) return false
|
||||||
|
keys.add(entry.key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseKeybind(config: string): Keybind[] {
|
export function parseKeybind(config: string): Keybind[] {
|
||||||
|
|
@ -281,7 +290,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const all: CommandOption[] = []
|
const all: CommandOption[] = []
|
||||||
|
|
||||||
for (const reg of store.registrations) {
|
for (const reg of activeCommandRegistrations(store.registrations)) {
|
||||||
for (const opt of reg.options()) {
|
for (const opt of reg.options()) {
|
||||||
if (seen.has(opt.id)) {
|
if (seen.has(opt.id)) {
|
||||||
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
|
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
|
||||||
|
|
@ -425,7 +434,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||||
key: id,
|
key: id,
|
||||||
options,
|
options,
|
||||||
}
|
}
|
||||||
setStore("registrations", (arr) => upsertCommandRegistration(arr, entry))
|
setStore("registrations", (arr) => addCommandRegistration(arr, entry))
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
setStore("registrations", (arr) => arr.filter((x) => x !== entry))
|
setStore("registrations", (arr) => arr.filter((x) => x !== entry))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,18 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||||
import type { createServerSdkContext } from "./server-sdk"
|
import type { createServerSdkContext } from "./server-sdk"
|
||||||
import type { createServerSyncContextInner } from "./server-sync"
|
import type { createServerSyncContextInner } from "./server-sync"
|
||||||
import type { State } from "./global-sync/types"
|
import type { State } from "./global-sync/types"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
const sessionFields = new Set([
|
const sessionFields = new Set([
|
||||||
"session_status",
|
"session_status",
|
||||||
"session_working",
|
"session_working",
|
||||||
"session_diff",
|
"session_diff",
|
||||||
|
"todo",
|
||||||
"permission",
|
"permission",
|
||||||
"question",
|
"question",
|
||||||
"message",
|
"message",
|
||||||
|
"session_message",
|
||||||
"part",
|
"part",
|
||||||
"part_text_accum_delta",
|
"part_text_accum_delta",
|
||||||
])
|
])
|
||||||
|
|
@ -113,7 +116,7 @@ export const createDirSyncContext = (
|
||||||
await serverSync.session.sync(sessionID, options)
|
await serverSync.session.sync(sessionID, options)
|
||||||
index(sessionID)
|
index(sessionID)
|
||||||
},
|
},
|
||||||
diff: serverSync.session.diff,
|
todo: serverSync.session.todo,
|
||||||
history: serverSync.session.history,
|
history: serverSync.session.history,
|
||||||
evict(sessionID: string) {
|
evict(sessionID: string) {
|
||||||
serverSync.session.evict(sessionID)
|
serverSync.session.evict(sessionID)
|
||||||
|
|
@ -121,9 +124,9 @@ export const createDirSyncContext = (
|
||||||
fetch: async (count = 10) => {
|
fetch: async (count = 10) => {
|
||||||
const [store, setStore] = current()
|
const [store, setStore] = current()
|
||||||
setStore("limit", (value) => value + count)
|
setStore("limit", (value) => value + count)
|
||||||
const response = await client.session.list()
|
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
|
||||||
const sessions = (response.data ?? [])
|
const sessions = response.data
|
||||||
.filter((session) => !!session?.id)
|
.map(normalizeSessionInfo)
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
.slice(0, store.limit)
|
.slice(0, store.limit)
|
||||||
sessions.forEach(serverSync.session.remember)
|
sessions.forEach(serverSync.session.remember)
|
||||||
|
|
@ -131,7 +134,8 @@ export const createDirSyncContext = (
|
||||||
},
|
},
|
||||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||||
archive: async (sessionID: string) => {
|
archive: async (sessionID: string) => {
|
||||||
await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
|
if ((await serverSDK.protocol) !== "v1") return
|
||||||
|
await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } })
|
||||||
current()[1](
|
current()[1](
|
||||||
"session",
|
"session",
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
|
||||||
|
|
@ -204,10 +204,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
}
|
}
|
||||||
|
|
||||||
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
||||||
sdk()
|
serverSDK()
|
||||||
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
|
.api.file.find(
|
||||||
|
{
|
||||||
|
location: { directory: sdk().directory },
|
||||||
|
query,
|
||||||
|
type: dirs === "true" ? "directory" : "file",
|
||||||
|
limit: options?.limit,
|
||||||
|
},
|
||||||
|
{ signal: options?.signal },
|
||||||
|
)
|
||||||
.then(
|
.then(
|
||||||
(x) => (x.data ?? []).map(path.normalize),
|
(x) => x.data.map((entry) => path.normalize(entry.path)),
|
||||||
(error) => {
|
(error) => {
|
||||||
if (options?.signal?.aborted) throw error
|
if (options?.signal?.aborted) throw error
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,29 @@ describe("file path helpers", () => {
|
||||||
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
|
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("normalizes Windows directory separators", () => {
|
||||||
|
const path = createPathHelpers(() => "C:\\repo")
|
||||||
|
expect(path.normalizeDir("frontend\\")).toBe("frontend")
|
||||||
|
expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src")
|
||||||
|
expect(path.normalizeDir("C:\\repo\\frontend\\")).toBe("frontend")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes separators for Windows roots written with forward slashes", () => {
|
||||||
|
const path = createPathHelpers(() => "C:/repo")
|
||||||
|
expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes separators for Windows UNC roots", () => {
|
||||||
|
const path = createPathHelpers(() => "\\\\server\\share")
|
||||||
|
expect(path.normalizeDir("\\\\server\\share\\frontend\\")).toBe("frontend")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves backslashes in POSIX directory names", () => {
|
||||||
|
const path = createPathHelpers(() => "/repo")
|
||||||
|
expect(path.normalizeDir("literal\\name\\")).toBe("literal\\name\\")
|
||||||
|
expect(path.normalizeDir("literal\\name/")).toBe("literal\\name")
|
||||||
|
})
|
||||||
|
|
||||||
test("keeps query/hash stripping behavior stable", () => {
|
test("keeps query/hash stripping behavior stable", () => {
|
||||||
expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
|
expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
|
||||||
expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")
|
expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,12 @@ export function createPathHelpers(scope: () => string) {
|
||||||
return normalize(tabValue)
|
return normalize(tabValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "")
|
const normalizeDir = (input: string) => {
|
||||||
|
const path = normalize(input)
|
||||||
|
const root = scope()
|
||||||
|
const windows = /^[A-Za-z]:/.test(root) || root.startsWith("\\\\")
|
||||||
|
return (windows ? path.replace(/\\/g, "/") : path).replace(/\/+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
normalize,
|
normalize,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
|
||||||
const refresh: string[] = []
|
const refresh: string[] = []
|
||||||
invalidateFromWatcher(
|
invalidateFromWatcher(
|
||||||
{
|
{
|
||||||
type: "filesystem.changed",
|
type: "file.watcher.updated",
|
||||||
properties: {
|
properties: {
|
||||||
file: "src/new.ts",
|
file: "src/new.ts",
|
||||||
event: "add",
|
event: "add",
|
||||||
|
|
@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
|
||||||
|
|
||||||
invalidateFromWatcher(
|
invalidateFromWatcher(
|
||||||
{
|
{
|
||||||
type: "filesystem.changed",
|
type: "file.watcher.updated",
|
||||||
properties: {
|
properties: {
|
||||||
file: "src/open.ts",
|
file: "src/open.ts",
|
||||||
event: "change",
|
event: "change",
|
||||||
|
|
@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
|
||||||
|
|
||||||
invalidateFromWatcher(
|
invalidateFromWatcher(
|
||||||
{
|
{
|
||||||
type: "filesystem.changed",
|
type: "file.watcher.updated",
|
||||||
properties: {
|
properties: {
|
||||||
file: "src",
|
file: "src",
|
||||||
event: "change",
|
event: "change",
|
||||||
|
|
@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
|
||||||
|
|
||||||
invalidateFromWatcher(
|
invalidateFromWatcher(
|
||||||
{
|
{
|
||||||
type: "filesystem.changed",
|
type: "file.watcher.updated",
|
||||||
properties: {
|
properties: {
|
||||||
file: "src/file.ts",
|
file: "src/file.ts",
|
||||||
event: "change",
|
event: "change",
|
||||||
|
|
@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
|
||||||
|
|
||||||
invalidateFromWatcher(
|
invalidateFromWatcher(
|
||||||
{
|
{
|
||||||
type: "filesystem.changed",
|
type: "file.watcher.updated",
|
||||||
properties: {
|
properties: {
|
||||||
file: ".git/index.lock",
|
file: ".git/index.lock",
|
||||||
event: "change",
|
event: "change",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ type WatcherOps = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||||
if (event.type !== "filesystem.changed") return
|
if (event.type !== "file.watcher.updated") return
|
||||||
const props =
|
const props =
|
||||||
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
||||||
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,41 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
|
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
|
||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
|
import {
|
||||||
|
bootstrapDirectory,
|
||||||
|
loadAgentsQuery,
|
||||||
|
loadCommands,
|
||||||
|
loadPathQuery,
|
||||||
|
loadProjectsQuery,
|
||||||
|
loadProvidersQuery,
|
||||||
|
loadReferencesQuery,
|
||||||
|
} from "./bootstrap"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { createServerSession } from "../server-session"
|
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
|
type ProjectApi = ServerApi["project"]
|
||||||
|
|
||||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||||
|
const api = {
|
||||||
|
agent: { list: async () => ({ location: {}, data: [] }) },
|
||||||
|
provider: { list: async () => ({ location: {}, data: [] }) },
|
||||||
|
model: {
|
||||||
|
list: async () => ({ location: {}, data: [] }),
|
||||||
|
default: async () => ({ location: {}, data: null }),
|
||||||
|
},
|
||||||
|
permission: { request: { list: async () => ({ location: {}, data: [] }) } },
|
||||||
|
project: {
|
||||||
|
list: async () => [],
|
||||||
|
current: async () => ({ id: "project", directory: "/project" }),
|
||||||
|
},
|
||||||
|
question: { request: { list: async () => ({ location: {}, data: [] }) } },
|
||||||
|
reference: { list: async () => ({ location: {}, data: [] }) },
|
||||||
|
vcs: { get: async () => ({ location: {}, data: {} }) },
|
||||||
|
} as unknown as ServerApi
|
||||||
|
|
||||||
function directoryState() {
|
function directoryState() {
|
||||||
return createStore<State>({
|
return createStore<State>({
|
||||||
|
|
@ -30,6 +57,7 @@ function directoryState() {
|
||||||
return this.session_status[id]?.type !== "idle"
|
return this.session_status[id]?.type !== "idle"
|
||||||
},
|
},
|
||||||
session_diff: {},
|
session_diff: {},
|
||||||
|
todo: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
question: {},
|
||||||
mcp_ready: true,
|
mcp_ready: true,
|
||||||
|
|
@ -40,20 +68,21 @@ function directoryState() {
|
||||||
vcs: undefined,
|
vcs: undefined,
|
||||||
limit: 5,
|
limit: 5,
|
||||||
message: {},
|
message: {},
|
||||||
|
session_message: {},
|
||||||
part: {},
|
part: {},
|
||||||
part_text_accum_delta: {},
|
part_text_accum_delta: {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("bootstrapDirectory", () => {
|
describe("bootstrapDirectory", () => {
|
||||||
test("marks a loading directory partial during bootstrap and complete after success", async () => {
|
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
|
||||||
const mcpReads: string[] = []
|
const mcpReads: string[] = []
|
||||||
const [store, setStore] = directoryState()
|
const [store, setStore] = directoryState()
|
||||||
|
|
||||||
await bootstrapDirectory({
|
await bootstrapDirectory({
|
||||||
directory: "/project",
|
directory: "/project",
|
||||||
scope: ServerScope.local,
|
scope: ServerScope.local,
|
||||||
mcp: false,
|
mcp: true,
|
||||||
global: {
|
global: {
|
||||||
config: {} satisfies Config,
|
config: {} satisfies Config,
|
||||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||||
|
|
@ -80,14 +109,24 @@ describe("bootstrapDirectory", () => {
|
||||||
return { data: {} }
|
return { data: {} }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
resource: {
|
||||||
|
list: async () => {
|
||||||
|
mcpReads.push("resource")
|
||||||
|
return { data: {} }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||||
} as unknown as OpencodeClient,
|
} as unknown as OpencodeClient,
|
||||||
|
api,
|
||||||
store,
|
store,
|
||||||
setStore,
|
setStore,
|
||||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||||
loadSessions() {},
|
loadSessions() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryClient: new QueryClient(),
|
queryClient: new QueryClient(),
|
||||||
|
protocol: Promise.resolve("v1"),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.status).toBe("partial")
|
expect(store.status).toBe("partial")
|
||||||
|
|
@ -95,81 +134,110 @@ describe("bootstrapDirectory", () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||||
|
|
||||||
expect(store.status).toBe("complete")
|
expect(store.status).toBe("complete")
|
||||||
expect(mcpReads).toEqual([])
|
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
|
||||||
})
|
|
||||||
|
|
||||||
test("seeds session status even while warming session info stalls", async () => {
|
|
||||||
const [store, setStore] = directoryState()
|
|
||||||
const stalled = Promise.withResolvers<never>()
|
|
||||||
const client = {
|
|
||||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
|
||||||
config: { get: async () => ({ data: {} }) },
|
|
||||||
session: {
|
|
||||||
status: async () => ({ data: { ses_busy: { type: "busy" } } }),
|
|
||||||
get: () => stalled.promise,
|
|
||||||
},
|
|
||||||
vcs: { get: async () => ({ data: undefined }) },
|
|
||||||
command: { list: async () => ({ data: [] }) },
|
|
||||||
permission: { list: async () => ({ data: [] }) },
|
|
||||||
question: { list: async () => ({ data: [] }) },
|
|
||||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
|
||||||
mcp: { status: async () => ({ data: {} }) },
|
|
||||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
|
||||||
} as unknown as OpencodeClient
|
|
||||||
const session = createServerSession(client)
|
|
||||||
const stale: Session = {
|
|
||||||
id: "ses_stale",
|
|
||||||
slug: "ses_stale",
|
|
||||||
projectID: "project",
|
|
||||||
directory: "/project",
|
|
||||||
title: "stale",
|
|
||||||
version: "1",
|
|
||||||
time: { created: 1, updated: 1 },
|
|
||||||
}
|
|
||||||
session.remember(stale)
|
|
||||||
session.set("session_status", stale.id, { type: "busy" })
|
|
||||||
|
|
||||||
await bootstrapDirectory({
|
|
||||||
directory: "/project",
|
|
||||||
scope: ServerScope.local,
|
|
||||||
mcp: false,
|
|
||||||
global: {
|
|
||||||
config: {} satisfies Config,
|
|
||||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
|
||||||
project: [{ id: "project", worktree: "/project" } as Project],
|
|
||||||
provider,
|
|
||||||
},
|
|
||||||
sdk: client,
|
|
||||||
store,
|
|
||||||
setStore,
|
|
||||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
|
||||||
loadSessions() {},
|
|
||||||
translate: (key) => key,
|
|
||||||
queryClient: new QueryClient(),
|
|
||||||
session,
|
|
||||||
})
|
|
||||||
|
|
||||||
const deadline = Date.now() + 500
|
|
||||||
while (!session.data.session_working("ses_busy") && Date.now() < deadline) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(session.data.session_status["ses_busy"]?.type).toBe("busy")
|
|
||||||
expect(session.data.session_status[stale.id]).toBeUndefined()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("query keys", () => {
|
describe("query keys", () => {
|
||||||
test("partitions identical directories by server scope", () => {
|
test("partitions identical directories by server scope", () => {
|
||||||
const client = {} as OpencodeClient
|
const client = {} as Parameters<typeof loadPathQuery>[2]
|
||||||
|
const api = {} as CatalogApi
|
||||||
const remote = "https://debian.example" as typeof ServerScope.local
|
const remote = "https://debian.example" as typeof ServerScope.local
|
||||||
|
|
||||||
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
|
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
|
||||||
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||||
expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
|
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
|
||||||
"https://debian.example",
|
})
|
||||||
null,
|
|
||||||
"providers",
|
test("loads the current provider and model catalog", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const api = {
|
||||||
|
provider: {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
calls.push(["provider", input])
|
||||||
|
return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
calls.push(["model", input])
|
||||||
|
return { location: {}, data: [] }
|
||||||
|
},
|
||||||
|
default: async (input: unknown) => {
|
||||||
|
calls.push(["default", input])
|
||||||
|
return { location: {}, data: null }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as CatalogApi
|
||||||
|
|
||||||
|
const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api))
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
["provider", { location: { directory: "/repo" } }],
|
||||||
|
["model", { location: { directory: "/repo" } }],
|
||||||
|
["default", { location: { directory: "/repo" } }],
|
||||||
])
|
])
|
||||||
|
expect(result.connected).toEqual(["openai"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads agents from the current location-scoped endpoint", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const api = {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
calls.push(input)
|
||||||
|
return { location: {}, data: [] }
|
||||||
|
},
|
||||||
|
} as unknown as AgentApi
|
||||||
|
|
||||||
|
const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api))
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads commands from the current location-scoped endpoint", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const api = {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
calls.push(input)
|
||||||
|
return {
|
||||||
|
location: {},
|
||||||
|
data: [{ name: "review", template: "Review files" /* source: "command" as const */ }],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as unknown as CommandApi
|
||||||
|
|
||||||
|
const result = await loadCommands("/repo", api)
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||||
|
expect(result).toEqual([{ name: "review", template: "Review files" /* source: "command" */ }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads projects from the current endpoint", async () => {
|
||||||
|
const api = {
|
||||||
|
list: async () => [
|
||||||
|
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||||
|
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||||
|
],
|
||||||
|
} as unknown as ProjectApi
|
||||||
|
|
||||||
|
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
|
||||||
|
|
||||||
|
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads references from the current location-scoped endpoint", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const api = {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
calls.push(input)
|
||||||
|
return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] }
|
||||||
|
},
|
||||||
|
} as unknown as ReferenceApi
|
||||||
|
|
||||||
|
const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api))
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,20 @@ import type {
|
||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
Session,
|
Session,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type {
|
||||||
|
AgentListInput,
|
||||||
|
AgentListOutput,
|
||||||
|
CatalogApi,
|
||||||
|
CommandInfo,
|
||||||
|
CommandListInput,
|
||||||
|
CommandListOutput,
|
||||||
|
ProjectCurrentInput,
|
||||||
|
ProjectCurrentOutput,
|
||||||
|
ProjectListOutput,
|
||||||
|
ReferenceListInput,
|
||||||
|
ReferenceListOutput,
|
||||||
|
SessionApi,
|
||||||
|
} from "@opencode-ai/client/promise"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { retry } from "@opencode-ai/core/util/retry"
|
import { retry } from "@opencode-ai/core/util/retry"
|
||||||
|
|
@ -16,12 +30,21 @@ import { batch } from "solid-js"
|
||||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import type { ServerSession } from "../server-session"
|
import type { ServerSession } from "../server-session"
|
||||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
import {
|
||||||
|
cmp,
|
||||||
|
normalizeAgentList,
|
||||||
|
normalizePermissionRequest,
|
||||||
|
normalizeProjectInfo,
|
||||||
|
normalizeProviderList,
|
||||||
|
} from "./utils"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
import type { ServerProtocol } from "@/utils/server-protocol"
|
||||||
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
|
|
@ -88,15 +111,26 @@ export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =
|
||||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
type ProjectApi = {
|
||||||
|
readonly list: () => Promise<ProjectListOutput>
|
||||||
|
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
type McpApi = ServerApi["mcp"]
|
||||||
|
type PermissionApi = ServerApi["permission"]
|
||||||
|
type QuestionApi = ServerApi["question"]
|
||||||
|
type VcsApi = ServerApi["vcs"]
|
||||||
|
|
||||||
|
export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, "project"],
|
queryKey: [scope, "project"],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
sdk.project.list().then((x) => {
|
api.list().then((projects) => {
|
||||||
return (x.data ?? [])
|
return projects
|
||||||
.filter((p) => !!p?.id)
|
.filter((p) => !!p?.id)
|
||||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
|
.map(normalizeProjectInfo)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
}),
|
}),
|
||||||
|
|
@ -105,6 +139,8 @@ export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
serverSDK: OpencodeClient
|
serverSDK: OpencodeClient
|
||||||
|
serverAPI: CatalogApi & { readonly project: ProjectApi }
|
||||||
|
protocol?: Promise<ServerProtocol>
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
requestFailedTitle: string
|
requestFailedTitle: string
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
|
|
@ -114,11 +150,14 @@ export async function bootstrapGlobal(input: {
|
||||||
}) {
|
}) {
|
||||||
const slow = [
|
const slow = [
|
||||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
||||||
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
|
() =>
|
||||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
|
input.queryClient.fetchQuery(
|
||||||
|
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
||||||
|
),
|
||||||
|
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
|
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||||
.then((data) => input.setGlobalStore("project", data)),
|
.then((data) => input.setGlobalStore("project", data)),
|
||||||
]
|
]
|
||||||
await runAll(slow)
|
await runAll(slow)
|
||||||
|
|
@ -162,44 +201,126 @@ function warmSessions(input: {
|
||||||
ids: string[]
|
ids: string[]
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
sdk: OpencodeClient
|
api: SessionApi
|
||||||
}) {
|
}) {
|
||||||
const known = new Set(input.store.session.map((item) => item.id))
|
const known = new Set(input.store.session.map((item) => item.id))
|
||||||
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
||||||
if (ids.length === 0) return Promise.resolve()
|
if (ids.length === 0) return Promise.resolve()
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
ids.map((sessionID) =>
|
ids.map((sessionID) =>
|
||||||
retry(() => input.sdk.session.get({ sessionID })).then((x) => {
|
retry(() => input.api.get({ sessionID })).then((session) =>
|
||||||
const session = x.data
|
mergeSession(input.setStore, normalizeSessionInfo(session)),
|
||||||
if (!session?.id) return
|
),
|
||||||
mergeSession(input.setStore, session)
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
).then(() => undefined)
|
).then(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
export const loadProvidersQuery = (
|
||||||
|
scope: ServerScope,
|
||||||
|
directory: string | null,
|
||||||
|
sdk: CatalogApi,
|
||||||
|
legacy?: OpencodeClient,
|
||||||
|
protocol?: Promise<ServerProtocol>,
|
||||||
|
) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "providers"],
|
queryKey: [scope, directory, "providers"],
|
||||||
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
|
queryFn: () =>
|
||||||
|
retry(async () => {
|
||||||
|
if ((await protocol) === "v1" && legacy) {
|
||||||
|
const result = await legacy.provider.list()
|
||||||
|
return normalizeProviderList(result.data!)
|
||||||
|
}
|
||||||
|
const location = directory ? { location: { directory } } : undefined
|
||||||
|
const [providers, models, defaultModel] = await Promise.all([
|
||||||
|
sdk.provider.list(location),
|
||||||
|
sdk.model.list(location),
|
||||||
|
sdk.model.default(location),
|
||||||
|
])
|
||||||
|
return normalizeProviderList(providers.data, models.data, defaultModel.data)
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
type AgentListApi = {
|
||||||
|
readonly list: (input?: AgentListInput) => Promise<AgentListOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommandListApi = {
|
||||||
|
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReferenceListApi = {
|
||||||
|
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadAgentsQuery = (
|
||||||
|
scope: ServerScope,
|
||||||
|
directory: string,
|
||||||
|
sdk: AgentListApi,
|
||||||
|
legacy?: OpencodeClient,
|
||||||
|
protocol?: Promise<ServerProtocol>,
|
||||||
|
) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "agents"],
|
queryKey: [scope, directory, "agents"],
|
||||||
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
|
queryFn: () =>
|
||||||
|
retry(async () => {
|
||||||
|
if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? [])
|
||||||
|
return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
export const loadCommands = (
|
||||||
|
directory: string,
|
||||||
|
api: CommandListApi,
|
||||||
|
legacy?: OpencodeClient,
|
||||||
|
protocol?: Promise<ServerProtocol>,
|
||||||
|
): Promise<CommandInfo[]> =>
|
||||||
|
retry(async () => {
|
||||||
|
if ((await protocol) === "v1" && legacy) {
|
||||||
|
return ((await legacy.command.list()).data ?? []).map((command) => {
|
||||||
|
const [providerID, id] = command.model?.split("/") ?? []
|
||||||
|
return {
|
||||||
|
name: command.name,
|
||||||
|
template: command.template,
|
||||||
|
description: command.description,
|
||||||
|
agent: command.agent,
|
||||||
|
model: providerID && id ? { providerID, id } : undefined,
|
||||||
|
subtask: command.subtask,
|
||||||
|
// source: command.source === "skill" ? undefined : command.source,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return api.list({ location: { directory } }).then((result) => result.data)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loadPathQuery = (
|
||||||
|
scope: ServerScope,
|
||||||
|
directory: string | null,
|
||||||
|
sdk: OpencodeClient,
|
||||||
|
protocol?: Promise<ServerProtocol>,
|
||||||
|
) =>
|
||||||
queryOptions<Path>({
|
queryOptions<Path>({
|
||||||
queryKey: [scope, directory, "path"],
|
queryKey: [scope, directory, "path"],
|
||||||
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
|
queryFn: async () => {
|
||||||
|
if ((await protocol) !== "v1")
|
||||||
|
return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" }
|
||||||
|
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
export const loadReferencesQuery = (
|
||||||
|
scope: ServerScope,
|
||||||
|
directory: string,
|
||||||
|
api: ReferenceListApi,
|
||||||
|
legacy?: OpencodeClient,
|
||||||
|
protocol?: Promise<ServerProtocol>,
|
||||||
|
) =>
|
||||||
queryOptions<ReferenceInfo[]>({
|
queryOptions<ReferenceInfo[]>({
|
||||||
queryKey: [scope, directory, "references"] as const,
|
queryKey: [scope, directory, "references"] as const,
|
||||||
queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
|
queryFn: () =>
|
||||||
|
retry(async () => {
|
||||||
|
if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? []
|
||||||
|
return api.list({ location: { directory } }).then((result) => result.data)
|
||||||
|
}).catch(() => []),
|
||||||
placeholderData: [],
|
placeholderData: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -208,6 +329,17 @@ export async function bootstrapDirectory(input: {
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
mcp: boolean
|
mcp: boolean
|
||||||
sdk: OpencodeClient
|
sdk: OpencodeClient
|
||||||
|
api: CatalogApi & {
|
||||||
|
readonly agent: AgentListApi
|
||||||
|
readonly command: CommandListApi
|
||||||
|
readonly mcp: McpApi
|
||||||
|
readonly permission: PermissionApi
|
||||||
|
readonly project: ProjectApi
|
||||||
|
readonly question: QuestionApi
|
||||||
|
readonly reference: ReferenceListApi
|
||||||
|
readonly session: SessionApi
|
||||||
|
readonly vcs: VcsApi
|
||||||
|
}
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
vcsCache: VcsCache
|
vcsCache: VcsCache
|
||||||
|
|
@ -221,6 +353,7 @@ export async function bootstrapDirectory(input: {
|
||||||
}
|
}
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
session?: ServerSession
|
session?: ServerSession
|
||||||
|
protocol?: Promise<ServerProtocol>
|
||||||
}) {
|
}) {
|
||||||
const loading = input.store.status !== "complete"
|
const loading = input.store.status !== "complete"
|
||||||
const seededProject = projectID(input.directory, input.global.project)
|
const seededProject = projectID(input.directory, input.global.project)
|
||||||
|
|
@ -240,13 +373,15 @@ export async function bootstrapDirectory(input: {
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
|
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
|
||||||
.then((data) => input.setStore("agent", data)),
|
.then((data) => input.setStore("agent", data)),
|
||||||
() =>
|
() =>
|
||||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.session.status().then(async (x) => {
|
(async () => {
|
||||||
|
if ((await input.protocol) !== "v1") return
|
||||||
|
const x = await input.sdk.session.status()
|
||||||
if (!input.session) {
|
if (!input.session) {
|
||||||
input.setStore("session_status", x.data!)
|
input.setStore("session_status", x.data!)
|
||||||
return
|
return
|
||||||
|
|
@ -264,42 +399,57 @@ export async function bootstrapDirectory(input: {
|
||||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
for (const [sessionID, status] of Object.entries(statuses)) {
|
||||||
input.session.set("session_status", sessionID, reconcile(status))
|
input.session.set("session_status", sessionID, reconcile(status))
|
||||||
}
|
}
|
||||||
// Warm session info only after seeding statuses so a stalled session
|
|
||||||
// fetch cannot park busy indicators behind it, mirroring how live
|
|
||||||
// session.status events apply first and resolve info in the background.
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
||||||
)
|
)
|
||||||
}),
|
})(),
|
||||||
),
|
),
|
||||||
!seededProject &&
|
!seededProject &&
|
||||||
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
|
(() =>
|
||||||
|
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||||
|
input.setStore("project", project.id),
|
||||||
|
)),
|
||||||
!seededPath &&
|
!seededPath &&
|
||||||
(() =>
|
(() =>
|
||||||
input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
|
input.queryClient
|
||||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol))
|
||||||
if (next) input.setStore("project", next)
|
.then((data) => {
|
||||||
})),
|
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||||
|
if (next) input.setStore("project", next)
|
||||||
|
})),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(async () => {
|
||||||
input.sdk.vcs.get().then((x) => {
|
if ((await input.protocol) !== "v1") return
|
||||||
const next = x.data ?? input.store.vcs
|
return input.sdk.vcs.get().then((result) => {
|
||||||
|
const next = { branch: result.data?.branch, default_branch: result.data?.default_branch }
|
||||||
input.setStore("vcs", next)
|
input.setStore("vcs", next)
|
||||||
if (next) input.vcsCache.setStore("value", next)
|
if (next) input.vcsCache.setStore("value", next)
|
||||||
}),
|
})
|
||||||
|
}),
|
||||||
|
input.mcp &&
|
||||||
|
(() =>
|
||||||
|
loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) =>
|
||||||
|
input.setStore("command", commands),
|
||||||
|
)),
|
||||||
|
() =>
|
||||||
|
input.queryClient.fetchQuery(
|
||||||
|
loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol),
|
||||||
),
|
),
|
||||||
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
|
|
||||||
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
|
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.permission.list().then((x) => {
|
(async () => {
|
||||||
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
|
||||||
|
return input.api.permission.request
|
||||||
|
.list({ location: { directory: input.directory } })
|
||||||
|
.then((result) => result.data.map(normalizePermissionRequest))
|
||||||
|
})().then((permissions) => {
|
||||||
|
const ids = permissions.map((permission) => permission.sessionID)
|
||||||
const grouped = groupBySession(
|
const grouped = groupBySession(
|
||||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||||
)
|
)
|
||||||
const warm = input.session
|
const warm = input.session
|
||||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||||
return warm.then(() =>
|
return warm.then(() =>
|
||||||
batch(() => {
|
batch(() => {
|
||||||
const current = input.session?.data.permission ?? input.store.permission
|
const current = input.session?.data.permission ?? input.store.permission
|
||||||
|
|
@ -323,12 +473,19 @@ export async function bootstrapDirectory(input: {
|
||||||
),
|
),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.question.list().then((x) => {
|
(async () => {
|
||||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
|
||||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
return input.api.question.request
|
||||||
|
.list({ location: { directory: input.directory } })
|
||||||
|
.then((result) => result.data)
|
||||||
|
})().then((questions) => {
|
||||||
|
const ids = questions.map((question) => question.sessionID)
|
||||||
|
const grouped = groupBySession(
|
||||||
|
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
||||||
|
)
|
||||||
const warm = input.session
|
const warm = input.session
|
||||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||||
return warm.then(() =>
|
return warm.then(() =>
|
||||||
batch(() => {
|
batch(() => {
|
||||||
const current = input.session?.data.question ?? input.store.question
|
const current = input.session?.data.question ?? input.store.question
|
||||||
|
|
@ -351,17 +508,27 @@ export async function bootstrapDirectory(input: {
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
|
input.mcp &&
|
||||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
|
(() =>
|
||||||
|
input.queryClient.fetchQuery(
|
||||||
|
loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
|
||||||
|
)),
|
||||||
|
input.mcp &&
|
||||||
|
(() =>
|
||||||
|
input.queryClient.fetchQuery(
|
||||||
|
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
|
||||||
|
)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
|
input.queryClient
|
||||||
const project = getFilename(input.directory)
|
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
|
||||||
showToast({
|
.catch((err) => {
|
||||||
variant: "error",
|
const project = getFilename(input.directory)
|
||||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
showToast({
|
||||||
description: formatServerError(err, input.translate),
|
variant: "error",
|
||||||
})
|
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||||
}),
|
description: formatServerError(err, input.translate),
|
||||||
|
})
|
||||||
|
}),
|
||||||
].filter(Boolean) as (() => Promise<any>)[]
|
].filter(Boolean) as (() => Promise<any>)[]
|
||||||
|
|
||||||
await waitForPaint()
|
await waitForPaint()
|
||||||
|
|
|
||||||
|
|
@ -221,4 +221,56 @@ describe("createChildStoreManager", () => {
|
||||||
dispose()
|
dispose()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps non-bootstrapping children passive until a real directory access", () => {
|
||||||
|
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||||
|
const offset = querySingles.length
|
||||||
|
const bootstraps: string[] = []
|
||||||
|
|
||||||
|
const dispose = createOwner((owner) => {
|
||||||
|
manager = createChildStoreManager({
|
||||||
|
owner,
|
||||||
|
scope: ServerScope.local,
|
||||||
|
persist,
|
||||||
|
isBooting: () => false,
|
||||||
|
isLoadingSessions: () => false,
|
||||||
|
onBootstrap(directory) {
|
||||||
|
bootstraps.push(directory)
|
||||||
|
},
|
||||||
|
onMcp() {},
|
||||||
|
onDispose() {},
|
||||||
|
translate: (key) => key,
|
||||||
|
queryOptions: queryOptionsApi,
|
||||||
|
global: { provider },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!manager) throw new Error("manager required")
|
||||||
|
const [store] = manager.child("/project", { bootstrap: false })
|
||||||
|
const queries = querySingles.slice(offset)
|
||||||
|
|
||||||
|
expect(queries).toHaveLength(6)
|
||||||
|
expect(queries[0]?.().enabled).toBe(false)
|
||||||
|
expect(queries[3]?.().enabled).toBe(false)
|
||||||
|
expect(queries[4]?.().enabled).toBe(false)
|
||||||
|
expect(queries[5]?.().enabled).toBe(false)
|
||||||
|
expect(store.path.directory).toBe("/project")
|
||||||
|
expect(store.provider_ready).toBe(false)
|
||||||
|
expect(store.lsp_ready).toBe(false)
|
||||||
|
expect(bootstraps).toEqual([])
|
||||||
|
|
||||||
|
manager.child("/project")
|
||||||
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
|
expect(queries[3]?.().enabled).toBe(true)
|
||||||
|
expect(queries[4]?.().enabled).toBe(true)
|
||||||
|
expect(queries[5]?.().enabled).toBe(true)
|
||||||
|
expect(bootstraps).toEqual(["/project"])
|
||||||
|
|
||||||
|
manager.child("/project", { bootstrap: false })
|
||||||
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
|
} finally {
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ export function createChildStoreManager(input: {
|
||||||
const disposers = new Map<string, () => void>()
|
const disposers = new Map<string, () => void>()
|
||||||
const mcpDirectories = new Set<string>()
|
const mcpDirectories = new Set<string>()
|
||||||
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
|
const activeDirectories = new Set<string>()
|
||||||
|
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
|
|
||||||
const markKey = (key: DirectoryKey) => {
|
const markKey = (key: DirectoryKey) => {
|
||||||
if (!key) return
|
if (!key) return
|
||||||
|
|
@ -118,6 +120,8 @@ export function createChildStoreManager(input: {
|
||||||
lifecycle.delete(key)
|
lifecycle.delete(key)
|
||||||
mcpDirectories.delete(key)
|
mcpDirectories.delete(key)
|
||||||
mcpToggles.delete(key)
|
mcpToggles.delete(key)
|
||||||
|
activeDirectories.delete(key)
|
||||||
|
activationToggles.delete(key)
|
||||||
const dispose = disposers.get(key)
|
const dispose = disposers.get(key)
|
||||||
if (dispose) {
|
if (dispose) {
|
||||||
dispose()
|
dispose()
|
||||||
|
|
@ -182,20 +186,27 @@ export function createChildStoreManager(input: {
|
||||||
const initialMeta = meta[0].value
|
const initialMeta = meta[0].value
|
||||||
const initialIcon = icon[0].value
|
const initialIcon = icon[0].value
|
||||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||||
|
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
||||||
|
|
||||||
const pathQuery = useQuery(() => input.queryOptions.path(key))
|
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
||||||
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
||||||
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
||||||
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
|
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
|
||||||
const providerQuery = useQuery(() => input.queryOptions.providers(key))
|
const providerQuery = useQuery(() => ({
|
||||||
const referenceQuery = useQuery(() => input.queryOptions.references(key))
|
...input.queryOptions.providers(key),
|
||||||
|
enabled: instanceQueriesEnabled(),
|
||||||
|
}))
|
||||||
|
const referenceQuery = useQuery(() => ({
|
||||||
|
...input.queryOptions.references(key),
|
||||||
|
enabled: instanceQueriesEnabled(),
|
||||||
|
}))
|
||||||
|
|
||||||
const child = createStore<State>({
|
const child = createStore<State>({
|
||||||
project: "",
|
project: "",
|
||||||
projectMeta: initialMeta,
|
projectMeta: initialMeta,
|
||||||
icon: initialIcon,
|
icon: initialIcon,
|
||||||
get provider_ready() {
|
get provider_ready() {
|
||||||
return !providerQuery.isLoading
|
return instanceQueriesEnabled() && !providerQuery.isLoading
|
||||||
},
|
},
|
||||||
get provider() {
|
get provider() {
|
||||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||||
|
|
@ -223,6 +234,7 @@ export function createChildStoreManager(input: {
|
||||||
return (type ?? "idle") !== "idle"
|
return (type ?? "idle") !== "idle"
|
||||||
},
|
},
|
||||||
session_diff: {},
|
session_diff: {},
|
||||||
|
todo: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
question: {},
|
||||||
get mcp_ready() {
|
get mcp_ready() {
|
||||||
|
|
@ -235,7 +247,7 @@ export function createChildStoreManager(input: {
|
||||||
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
|
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
|
||||||
},
|
},
|
||||||
get lsp_ready() {
|
get lsp_ready() {
|
||||||
return !lspQuery.isLoading
|
return instanceQueriesEnabled() && !lspQuery.isLoading
|
||||||
},
|
},
|
||||||
get lsp() {
|
get lsp() {
|
||||||
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
||||||
|
|
@ -243,12 +255,14 @@ export function createChildStoreManager(input: {
|
||||||
vcs: vcsStore.value,
|
vcs: vcsStore.value,
|
||||||
limit: 5,
|
limit: 5,
|
||||||
message: {},
|
message: {},
|
||||||
|
session_message: {},
|
||||||
part: {},
|
part: {},
|
||||||
part_text_accum_delta: {},
|
part_text_accum_delta: {},
|
||||||
})
|
})
|
||||||
children[key] = child
|
children[key] = child
|
||||||
disposers.set(key, dispose)
|
disposers.set(key, dispose)
|
||||||
mcpToggles.set(key, setMcpEnabled)
|
mcpToggles.set(key, setMcpEnabled)
|
||||||
|
activationToggles.set(key, setInstanceQueriesEnabled)
|
||||||
|
|
||||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||||
if (!(init instanceof Promise)) return
|
if (!(init instanceof Promise)) return
|
||||||
|
|
@ -289,6 +303,7 @@ export function createChildStoreManager(input: {
|
||||||
pinForOwner(key)
|
pinForOwner(key)
|
||||||
if (options.mcp) enableMcp(directory, key, childStore)
|
if (options.mcp) enableMcp(directory, key, childStore)
|
||||||
const shouldBootstrap = options.bootstrap ?? true
|
const shouldBootstrap = options.bootstrap ?? true
|
||||||
|
if (shouldBootstrap) activate(key)
|
||||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||||
input.onBootstrap(directory)
|
input.onBootstrap(directory)
|
||||||
}
|
}
|
||||||
|
|
@ -300,6 +315,7 @@ export function createChildStoreManager(input: {
|
||||||
const childStore = ensureChild(directory)
|
const childStore = ensureChild(directory)
|
||||||
if (options.mcp) enableMcp(directory, key, childStore)
|
if (options.mcp) enableMcp(directory, key, childStore)
|
||||||
const shouldBootstrap = options.bootstrap ?? true
|
const shouldBootstrap = options.bootstrap ?? true
|
||||||
|
if (shouldBootstrap) activate(key)
|
||||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||||
input.onBootstrap(directory)
|
input.onBootstrap(directory)
|
||||||
}
|
}
|
||||||
|
|
@ -313,6 +329,16 @@ export function createChildStoreManager(input: {
|
||||||
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Passive Home/project metadata reads must not initialize the directory.
|
||||||
|
// A real directory access enables these queries once for the store lifetime.
|
||||||
|
// TODO(v2): After Home switches to v2.project.list and root-filtered,
|
||||||
|
// updated-time v2.session.list, remove any Home-only passive child creation.
|
||||||
|
function activate(key: DirectoryKey) {
|
||||||
|
if (activeDirectories.has(key)) return
|
||||||
|
activeDirectories.add(key)
|
||||||
|
activationToggles.get(key)?.(true)
|
||||||
|
}
|
||||||
|
|
||||||
function disableMcp(directory: string) {
|
function disableMcp(directory: string) {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
if (!mcpDirectories.delete(key)) return
|
if (!mcpDirectories.delete(key)) return
|
||||||
|
|
@ -359,6 +385,7 @@ export function createChildStoreManager(input: {
|
||||||
unpin,
|
unpin,
|
||||||
pinned,
|
pinned,
|
||||||
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
||||||
|
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
|
||||||
disableMcp,
|
disableMcp,
|
||||||
disposeDirectory,
|
disposeDirectory,
|
||||||
runEviction,
|
runEviction,
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ const baseState = (input: Partial<State> = {}) =>
|
||||||
sessionTotal: 0,
|
sessionTotal: 0,
|
||||||
session_status: {},
|
session_status: {},
|
||||||
session_diff: {},
|
session_diff: {},
|
||||||
|
todo: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
question: {},
|
||||||
mcp: {},
|
mcp: {},
|
||||||
|
|
@ -79,6 +80,7 @@ const baseState = (input: Partial<State> = {}) =>
|
||||||
vcs: undefined,
|
vcs: undefined,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
message: {},
|
message: {},
|
||||||
|
session_message: {},
|
||||||
part: {},
|
part: {},
|
||||||
part_text_accum_delta: {},
|
part_text_accum_delta: {},
|
||||||
...input,
|
...input,
|
||||||
|
|
@ -215,6 +217,7 @@ describe("applyDirectoryEvent", () => {
|
||||||
message: { ses_1: [message] },
|
message: { ses_1: [message] },
|
||||||
part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
|
part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
|
||||||
session_diff: { ses_1: [] },
|
session_diff: { ses_1: [] },
|
||||||
|
todo: { ses_1: [] },
|
||||||
permission: { ses_1: [] },
|
permission: { ses_1: [] },
|
||||||
question: { ses_1: [] },
|
question: { ses_1: [] },
|
||||||
session_status: { ses_1: { type: "busy" } },
|
session_status: { ses_1: { type: "busy" } },
|
||||||
|
|
@ -235,15 +238,32 @@ describe("applyDirectoryEvent", () => {
|
||||||
expect(store.message.ses_1).toBeUndefined()
|
expect(store.message.ses_1).toBeUndefined()
|
||||||
expect(store.part[message.id]).toBeUndefined()
|
expect(store.part[message.id]).toBeUndefined()
|
||||||
expect(store.session_diff.ses_1).toBeUndefined()
|
expect(store.session_diff.ses_1).toBeUndefined()
|
||||||
|
expect(store.todo.ses_1).toBeUndefined()
|
||||||
expect(store.permission.ses_1).toBeUndefined()
|
expect(store.permission.ses_1).toBeUndefined()
|
||||||
expect(store.question.ses_1).toBeUndefined()
|
expect(store.question.ses_1).toBeUndefined()
|
||||||
expect(store.session_status.ses_1).toBeUndefined()
|
expect(store.session_status.ses_1).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("ignores an archived session absent from a passive directory store", () => {
|
||||||
|
const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 }))
|
||||||
|
|
||||||
|
applyDirectoryEvent({
|
||||||
|
event: { type: "session.updated", properties: { info: rootSession({ id: "missing", archived: 10 }) } },
|
||||||
|
store,
|
||||||
|
setStore,
|
||||||
|
push() {},
|
||||||
|
directory: "/tmp",
|
||||||
|
loadLsp() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.session).toEqual([])
|
||||||
|
expect(store.sessionTotal).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
test("cleans session caches when deleted and decrements only root totals", () => {
|
test("cleans session caches when deleted and decrements only root totals", () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
|
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false },
|
||||||
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 },
|
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const item of cases) {
|
for (const item of cases) {
|
||||||
|
|
@ -259,6 +279,7 @@ describe("applyDirectoryEvent", () => {
|
||||||
message: { [item.info.id]: [message] },
|
message: { [item.info.id]: [message] },
|
||||||
part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] },
|
part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] },
|
||||||
session_diff: { [item.info.id]: [] },
|
session_diff: { [item.info.id]: [] },
|
||||||
|
todo: { [item.info.id]: [] },
|
||||||
permission: { [item.info.id]: [] },
|
permission: { [item.info.id]: [] },
|
||||||
question: { [item.info.id]: [] },
|
question: { [item.info.id]: [] },
|
||||||
session_status: { [item.info.id]: { type: "busy" } },
|
session_status: { [item.info.id]: { type: "busy" } },
|
||||||
|
|
@ -266,7 +287,10 @@ describe("applyDirectoryEvent", () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
event: { type: "session.deleted", properties: { info: item.info } },
|
event: {
|
||||||
|
type: "session.deleted",
|
||||||
|
properties: item.current ? { sessionID: item.info.id } : { info: item.info },
|
||||||
|
},
|
||||||
store,
|
store,
|
||||||
setStore,
|
setStore,
|
||||||
push() {},
|
push() {},
|
||||||
|
|
@ -279,6 +303,7 @@ describe("applyDirectoryEvent", () => {
|
||||||
expect(store.message[item.info.id]).toBeUndefined()
|
expect(store.message[item.info.id]).toBeUndefined()
|
||||||
expect(store.part[message.id]).toBeUndefined()
|
expect(store.part[message.id]).toBeUndefined()
|
||||||
expect(store.session_diff[item.info.id]).toBeUndefined()
|
expect(store.session_diff[item.info.id]).toBeUndefined()
|
||||||
|
expect(store.todo[item.info.id]).toBeUndefined()
|
||||||
expect(store.permission[item.info.id]).toBeUndefined()
|
expect(store.permission[item.info.id]).toBeUndefined()
|
||||||
expect(store.question[item.info.id]).toBeUndefined()
|
expect(store.question[item.info.id]).toBeUndefined()
|
||||||
expect(store.session_status[item.info.id]).toBeUndefined()
|
expect(store.session_status[item.info.id]).toBeUndefined()
|
||||||
|
|
@ -289,6 +314,7 @@ describe("applyDirectoryEvent", () => {
|
||||||
const dropped = rootSession({ id: "ses_b" })
|
const dropped = rootSession({ id: "ses_b" })
|
||||||
const kept = rootSession({ id: "ses_a" })
|
const kept = rootSession({ id: "ses_a" })
|
||||||
const message = userMessage("msg_1", dropped.id)
|
const message = userMessage("msg_1", dropped.id)
|
||||||
|
const todos: string[] = []
|
||||||
const [store, setStore] = createStore(
|
const [store, setStore] = createStore(
|
||||||
baseState({
|
baseState({
|
||||||
limit: 1,
|
limit: 1,
|
||||||
|
|
@ -296,6 +322,7 @@ describe("applyDirectoryEvent", () => {
|
||||||
message: { [dropped.id]: [message] },
|
message: { [dropped.id]: [message] },
|
||||||
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
|
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
|
||||||
session_diff: { [dropped.id]: [] },
|
session_diff: { [dropped.id]: [] },
|
||||||
|
todo: { [dropped.id]: [] },
|
||||||
permission: { [dropped.id]: [] },
|
permission: { [dropped.id]: [] },
|
||||||
question: { [dropped.id]: [] },
|
question: { [dropped.id]: [] },
|
||||||
session_status: { [dropped.id]: { type: "busy" } },
|
session_status: { [dropped.id]: { type: "busy" } },
|
||||||
|
|
@ -309,15 +336,21 @@ describe("applyDirectoryEvent", () => {
|
||||||
push() {},
|
push() {},
|
||||||
directory: "/tmp",
|
directory: "/tmp",
|
||||||
loadLsp() {},
|
loadLsp() {},
|
||||||
|
setSessionTodo(sessionID, value) {
|
||||||
|
if (value !== undefined) return
|
||||||
|
todos.push(sessionID)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.session.map((x) => x.id)).toEqual([kept.id])
|
expect(store.session.map((x) => x.id)).toEqual([kept.id])
|
||||||
expect(store.message[dropped.id]).toBeUndefined()
|
expect(store.message[dropped.id]).toBeUndefined()
|
||||||
expect(store.part[message.id]).toBeUndefined()
|
expect(store.part[message.id]).toBeUndefined()
|
||||||
expect(store.session_diff[dropped.id]).toBeUndefined()
|
expect(store.session_diff[dropped.id]).toBeUndefined()
|
||||||
|
expect(store.todo[dropped.id]).toBeUndefined()
|
||||||
expect(store.permission[dropped.id]).toBeUndefined()
|
expect(store.permission[dropped.id]).toBeUndefined()
|
||||||
expect(store.question[dropped.id]).toBeUndefined()
|
expect(store.question[dropped.id]).toBeUndefined()
|
||||||
expect(store.session_status[dropped.id]).toBeUndefined()
|
expect(store.session_status[dropped.id]).toBeUndefined()
|
||||||
|
expect(todos).toEqual([dropped.id])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
|
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@ import type {
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import type { SessionDiff } from "@/utils/diffs"
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
import { dropSessionCaches } from "./session-cache"
|
import { dropSessionCaches } from "./session-cache"
|
||||||
|
|
@ -18,6 +19,7 @@ import { diffs as list, message as clean } from "@/utils/diffs"
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const SESSION_CONTENT_EVENTS = new Set([
|
const SESSION_CONTENT_EVENTS = new Set([
|
||||||
"session.diff",
|
"session.diff",
|
||||||
|
"todo.updated",
|
||||||
"session.status",
|
"session.status",
|
||||||
"message.updated",
|
"message.updated",
|
||||||
"message.removed",
|
"message.removed",
|
||||||
|
|
@ -60,8 +62,13 @@ export function applyGlobalEvent(input: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: string) {
|
function cleanupSessionCaches(
|
||||||
|
setStore: SetStoreFunction<State>,
|
||||||
|
sessionID: string,
|
||||||
|
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||||
|
) {
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
setSessionTodo?.(sessionID, undefined)
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
dropSessionCaches(draft, [sessionID])
|
dropSessionCaches(draft, [sessionID])
|
||||||
|
|
@ -69,11 +76,17 @@ function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: stri
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, next: Session[]) {
|
export function cleanupDroppedSessionCaches(
|
||||||
|
store: Store<State>,
|
||||||
|
setStore: SetStoreFunction<State>,
|
||||||
|
next: Session[],
|
||||||
|
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||||
|
) {
|
||||||
const keep = new Set(next.map((item) => item.id))
|
const keep = new Set(next.map((item) => item.id))
|
||||||
const stale = [
|
const stale = [
|
||||||
...Object.keys(store.message),
|
...Object.keys(store.message),
|
||||||
...Object.keys(store.session_diff),
|
...Object.keys(store.session_diff),
|
||||||
|
...Object.keys(store.todo),
|
||||||
...Object.keys(store.permission),
|
...Object.keys(store.permission),
|
||||||
...Object.keys(store.question),
|
...Object.keys(store.question),
|
||||||
...Object.keys(store.session_status),
|
...Object.keys(store.session_status),
|
||||||
|
|
@ -82,6 +95,9 @@ export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetSt
|
||||||
.filter((sessionID): sessionID is string => !!sessionID),
|
.filter((sessionID): sessionID is string => !!sessionID),
|
||||||
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
|
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
|
||||||
if (stale.length === 0) return
|
if (stale.length === 0) return
|
||||||
|
for (const sessionID of stale) {
|
||||||
|
setSessionTodo?.(sessionID, undefined)
|
||||||
|
}
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
dropSessionCaches(draft, stale)
|
dropSessionCaches(draft, stale)
|
||||||
|
|
@ -98,6 +114,7 @@ export function applyDirectoryEvent(input: {
|
||||||
loadLsp: () => void
|
loadLsp: () => void
|
||||||
loadReferences?: () => void
|
loadReferences?: () => void
|
||||||
vcsCache?: VcsCache
|
vcsCache?: VcsCache
|
||||||
|
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
|
||||||
retainedLimit?: number
|
retainedLimit?: number
|
||||||
sessionContent?: boolean
|
sessionContent?: boolean
|
||||||
permission?: State["permission"]
|
permission?: State["permission"]
|
||||||
|
|
@ -121,7 +138,7 @@ export function applyDirectoryEvent(input: {
|
||||||
next.splice(result.index, 0, info)
|
next.splice(result.index, 0, info)
|
||||||
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
|
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
|
||||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed)
|
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||||
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -129,16 +146,15 @@ export function applyDirectoryEvent(input: {
|
||||||
const info = (event.properties as { info: Session }).info
|
const info = (event.properties as { info: Session }).info
|
||||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||||
if (info.time.archived) {
|
if (info.time.archived) {
|
||||||
|
if (!result.found) break
|
||||||
if (input.store.session[result.index]!.time.archived === info.time.archived) break
|
if (input.store.session[result.index]!.time.archived === info.time.archived) break
|
||||||
if (result.found) {
|
input.setStore(
|
||||||
input.setStore(
|
"session",
|
||||||
"session",
|
produce((draft) => {
|
||||||
produce((draft) => {
|
draft.splice(result.index, 1)
|
||||||
draft.splice(result.index, 1)
|
}),
|
||||||
}),
|
)
|
||||||
)
|
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||||
}
|
|
||||||
cleanupSessionCaches(input.setStore, info.id)
|
|
||||||
if (info.parentID) break
|
if (info.parentID) break
|
||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
break
|
break
|
||||||
|
|
@ -151,12 +167,15 @@ export function applyDirectoryEvent(input: {
|
||||||
next.splice(result.index, 0, info)
|
next.splice(result.index, 0, info)
|
||||||
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
|
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
|
||||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed)
|
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.deleted": {
|
case "session.deleted": {
|
||||||
const info = (event.properties as { info: Session }).info
|
const properties = event.properties as { sessionID?: string; info?: Session }
|
||||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
const sessionID = properties.info?.id ?? properties.sessionID
|
||||||
|
if (!sessionID) break
|
||||||
|
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
|
||||||
|
const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined)
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
input.setStore(
|
input.setStore(
|
||||||
"session",
|
"session",
|
||||||
|
|
@ -165,14 +184,83 @@ export function applyDirectoryEvent(input: {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
cleanupSessionCaches(input.setStore, info.id)
|
cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
|
||||||
if (info.parentID) break
|
if (info?.parentID) break
|
||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case "session.renamed": {
|
||||||
|
const properties = event.properties as { sessionID: string; title: string }
|
||||||
|
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||||
|
if (!result.found) break
|
||||||
|
input.setStore("session", result.index, (session) => ({
|
||||||
|
...session,
|
||||||
|
title: properties.title,
|
||||||
|
time: { ...session.time, updated: Date.now() },
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "session.usage.updated": {
|
||||||
|
const properties = event.properties as Pick<Session, "cost" | "tokens"> & { sessionID: string }
|
||||||
|
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||||
|
if (!result.found) break
|
||||||
|
input.setStore("session", result.index, (session) => ({
|
||||||
|
...session,
|
||||||
|
cost: properties.cost,
|
||||||
|
tokens: properties.tokens,
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// case "session.archived": {
|
||||||
|
// const properties = event.properties as { sessionID: string }
|
||||||
|
// const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||||
|
// if (!result.found) break
|
||||||
|
// const info = input.store.session[result.index]
|
||||||
|
// input.setStore(
|
||||||
|
// "session",
|
||||||
|
// produce((draft) => void draft.splice(result.index, 1)),
|
||||||
|
// )
|
||||||
|
// cleanupSessionCaches(input.setStore, properties.sessionID)
|
||||||
|
// if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
|
// break
|
||||||
|
// }
|
||||||
|
case "session.moved": {
|
||||||
|
const properties = event.properties as {
|
||||||
|
sessionID: string
|
||||||
|
location: { directory: string; workspaceID?: string }
|
||||||
|
projectID?: string
|
||||||
|
subpath?: string
|
||||||
|
}
|
||||||
|
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||||
|
if (!result.found) break
|
||||||
|
if (properties.location.directory === input.directory) {
|
||||||
|
input.setStore("session", result.index, (session) => ({
|
||||||
|
...session,
|
||||||
|
projectID: properties.projectID ?? session.projectID,
|
||||||
|
workspaceID: properties.location.workspaceID,
|
||||||
|
directory: properties.location.directory,
|
||||||
|
path: properties.subpath,
|
||||||
|
time: { ...session.time, updated: Date.now() },
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const info = input.store.session[result.index]
|
||||||
|
input.setStore(
|
||||||
|
"session",
|
||||||
|
produce((draft) => void draft.splice(result.index, 1)),
|
||||||
|
)
|
||||||
|
if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
|
break
|
||||||
|
}
|
||||||
case "session.diff": {
|
case "session.diff": {
|
||||||
const props = event.properties as { sessionID: string; diff: SessionDiff[] }
|
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" }))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "todo.updated": {
|
||||||
|
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||||
|
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||||
|
input.setSessionTodo?.(props.sessionID, props.todos)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.status": {
|
case "session.status": {
|
||||||
|
|
|
||||||
154
packages/app/src/context/global-sync/home-session-index.test.ts
Normal file
154
packages/app/src/context/global-sync/home-session-index.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { SessionV2Info } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import {
|
||||||
|
applyHomeSessionEvent,
|
||||||
|
appendHomeSessionEvent,
|
||||||
|
HOME_V2_SESSION_PAGE_LIMIT,
|
||||||
|
loadHomeSessionIndex,
|
||||||
|
homeSessionIndexSessions,
|
||||||
|
homeSessionIndexRefresh,
|
||||||
|
parseHomeSessionIndex,
|
||||||
|
retainHomeSessions,
|
||||||
|
} from "./home-session-index"
|
||||||
|
|
||||||
|
const session = (input: {
|
||||||
|
id: string
|
||||||
|
directory?: string
|
||||||
|
parentID?: string
|
||||||
|
archived?: number
|
||||||
|
updated?: number
|
||||||
|
}) => ({
|
||||||
|
id: input.id,
|
||||||
|
parentID: input.parentID,
|
||||||
|
projectID: "project",
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 1, updated: input.updated ?? 1, archived: input.archived },
|
||||||
|
title: input.id,
|
||||||
|
location: { directory: input.directory ?? "/project" },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Home V2 session index", () => {
|
||||||
|
test("loads the Home index with one global V2 request", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const result = await loadHomeSessionIndex(async (input) => {
|
||||||
|
calls.push(input)
|
||||||
|
return { data: { data: [session({ id: "root" })], cursor: {} } }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.sessions).toHaveLength(1)
|
||||||
|
expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads subsequent pages until the session index is complete", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const controller = new AbortController()
|
||||||
|
const result = await loadHomeSessionIndex(
|
||||||
|
async (input, options) => {
|
||||||
|
calls.push({ input, signal: options.signal })
|
||||||
|
if (!("cursor" in input)) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
|
||||||
|
session({ id: `page-1-${index}` }),
|
||||||
|
),
|
||||||
|
cursor: { next: "next-page" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { data: { data: [session({ id: "page-2" })], cursor: {} } }
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
controller.signal,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{ input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal },
|
||||||
|
{
|
||||||
|
input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" },
|
||||||
|
signal: controller.signal,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("maps visible roots to Home session summaries", () => {
|
||||||
|
const activeNull = {
|
||||||
|
...session({ id: "active-null", updated: 20 }),
|
||||||
|
time: { created: 1, updated: 20, archived: null },
|
||||||
|
} as unknown as SessionV2Info
|
||||||
|
const result = parseHomeSessionIndex([
|
||||||
|
session({ id: "root", updated: 30 }),
|
||||||
|
activeNull,
|
||||||
|
session({ id: "child", parentID: "root", updated: 40 }),
|
||||||
|
session({ id: "archived", archived: 50, updated: 50 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "root",
|
||||||
|
slug: "root",
|
||||||
|
version: "",
|
||||||
|
directory: "/project",
|
||||||
|
projectID: "project",
|
||||||
|
title: "root",
|
||||||
|
time: { created: 1, updated: 30 },
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "active-null",
|
||||||
|
time: { created: 1, updated: 20, archived: null },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves the per-directory Home retention limit", () => {
|
||||||
|
const now = 10 * 60 * 60 * 1000
|
||||||
|
const sessions = Array.from({ length: 80 }, (_, index) => ({
|
||||||
|
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
|
||||||
|
directory: index % 2 === 0 ? "/one" : "/two",
|
||||||
|
}))
|
||||||
|
|
||||||
|
const retained = retainHomeSessions(sessions, 10, now)
|
||||||
|
expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
|
||||||
|
expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replays session events over the loaded index", () => {
|
||||||
|
const initial = parseHomeSessionIndex([session({ id: "old" })])
|
||||||
|
const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
|
||||||
|
|
||||||
|
const afterCreate = applyHomeSessionEvent(initial, {
|
||||||
|
type: "session.created",
|
||||||
|
properties: { sessionID: created.id, info: created },
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
applyHomeSessionEvent(afterCreate, {
|
||||||
|
type: "session.deleted",
|
||||||
|
properties: { sessionID: initial[0]!.id, info: initial[0]! },
|
||||||
|
}),
|
||||||
|
).toEqual([created])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("applies only events newer than the index baseline", () => {
|
||||||
|
const initial = parseHomeSessionIndex([session({ id: "old" })])
|
||||||
|
const stale = { ...initial[0], title: "stale" }
|
||||||
|
const current = { ...initial[0], title: "current" }
|
||||||
|
const first = appendHomeSessionEvent(undefined, {
|
||||||
|
type: "session.updated",
|
||||||
|
properties: { sessionID: stale.id, info: stale },
|
||||||
|
})
|
||||||
|
const events = appendHomeSessionEvent(first, {
|
||||||
|
type: "session.updated",
|
||||||
|
properties: { sessionID: current.id, info: current },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refetches after reconnect, disposal, and session moves", () => {
|
||||||
|
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
|
||||||
|
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
|
||||||
|
expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
|
||||||
|
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
174
packages/app/src/context/global-sync/home-session-index.ts
Normal file
174
packages/app/src/context/global-sync/home-session-index.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { QueryClient } from "@tanstack/solid-query"
|
||||||
|
import { trimSessions } from "./session-trim"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
|
||||||
|
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
|
||||||
|
|
||||||
|
export type HomeSessionEvent = {
|
||||||
|
type: "session.created" | "session.updated" | "session.deleted"
|
||||||
|
properties: { sessionID: string; info: Session }
|
||||||
|
}
|
||||||
|
export type HomeSessionEvents = {
|
||||||
|
sequence: number
|
||||||
|
entries: Array<{ sequence: number; event: HomeSessionEvent }>
|
||||||
|
}
|
||||||
|
export type HomeSessionIndex = {
|
||||||
|
sessions: Session[]
|
||||||
|
eventSequence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
|
||||||
|
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
|
||||||
|
|
||||||
|
type HomeSessionPage = { data?: V2SessionListResponse }
|
||||||
|
|
||||||
|
export async function loadHomeSessionIndex(
|
||||||
|
list: (
|
||||||
|
input: { limit: number; order: "desc"; cursor?: string },
|
||||||
|
options: { signal?: AbortSignal },
|
||||||
|
) => Promise<HomeSessionPage>,
|
||||||
|
eventSequence = 0,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
const data: SessionV2Info[] = []
|
||||||
|
let cursor: string | undefined
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const response = await list(
|
||||||
|
{
|
||||||
|
limit: HOME_V2_SESSION_PAGE_LIMIT,
|
||||||
|
order: "desc",
|
||||||
|
...(cursor ? { cursor } : {}),
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
)
|
||||||
|
const page = response.data!
|
||||||
|
data.push(...page.data)
|
||||||
|
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
|
||||||
|
return { sessions: parseHomeSessionIndex(data), eventSequence }
|
||||||
|
cursor = page.cursor.next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) {
|
||||||
|
const sequence = (current?.sequence ?? 0) + 1
|
||||||
|
return {
|
||||||
|
sequence,
|
||||||
|
entries: [...(current?.entries ?? []), { sequence, event }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents {
|
||||||
|
return {
|
||||||
|
sequence: current?.sequence ?? sequence,
|
||||||
|
entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
|
||||||
|
if (!index) return []
|
||||||
|
return (events?.entries ?? [])
|
||||||
|
.filter((entry) => entry.sequence > index.eventSequence)
|
||||||
|
.reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
|
||||||
|
if (event === "server.connected") return { connected: true, refetch: connected }
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
refetch: event === "global.disposed" || event === "session.next.moved",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
|
||||||
|
const indexKey = homeSessionIndexKey(server)
|
||||||
|
const eventsKey = homeSessionEventsKey(server)
|
||||||
|
let connected = false
|
||||||
|
|
||||||
|
return {
|
||||||
|
indexKey,
|
||||||
|
eventsKey,
|
||||||
|
eventSequence() {
|
||||||
|
return queryClient.getQueryData<HomeSessionEvents>(eventsKey)?.sequence ?? 0
|
||||||
|
},
|
||||||
|
complete(sequence: number) {
|
||||||
|
// Keep events received after the fetch began so its response cannot overwrite them.
|
||||||
|
queryClient.setQueryData<HomeSessionEvents>(eventsKey, (current) => trimHomeSessionEvents(current, sequence))
|
||||||
|
},
|
||||||
|
sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
|
||||||
|
return homeSessionIndexSessions(index, events)
|
||||||
|
},
|
||||||
|
apply(event: HomeSessionEvent) {
|
||||||
|
if (!queryClient.getQueryState(indexKey)) return
|
||||||
|
const next = appendHomeSessionEvent(queryClient.getQueryData<HomeSessionEvents>(eventsKey), event)
|
||||||
|
if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) {
|
||||||
|
queryClient.setQueryData(eventsKey, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = queryClient.getQueryData<HomeSessionIndex>(indexKey)
|
||||||
|
if (index) {
|
||||||
|
queryClient.setQueryData<HomeSessionIndex>(indexKey, {
|
||||||
|
sessions: homeSessionIndexSessions(index, next),
|
||||||
|
eventSequence: next.sequence,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
queryClient.setQueryData<HomeSessionEvents>(eventsKey, { sequence: next.sequence, entries: [] })
|
||||||
|
},
|
||||||
|
refresh(event: Event["type"]) {
|
||||||
|
const result = homeSessionIndexRefresh(event, connected)
|
||||||
|
connected = result.connected
|
||||||
|
if (!result.refetch) return
|
||||||
|
void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(v2): This deliberately dumb full-table scan is necessary because the
|
||||||
|
// current V2 API orders by creation time and cannot filter roots, archives, or
|
||||||
|
// multiple directories. A bounded page could omit an old session updated today.
|
||||||
|
// Once released, use client.v2.project.list() and client.v2.session.list({
|
||||||
|
// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
|
||||||
|
export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
|
||||||
|
return sessions.flatMap((item) => {
|
||||||
|
if (item.parentID || typeof item.time.archived === "number") return []
|
||||||
|
return [toLegacySummary(item)]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
|
||||||
|
const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
|
||||||
|
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
|
||||||
|
const info = event.properties.info
|
||||||
|
const index = sessions.findIndex((session) => session.id === info.id)
|
||||||
|
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
|
||||||
|
if (index === -1) return sessions
|
||||||
|
return sessions.toSpliced(index, 1)
|
||||||
|
}
|
||||||
|
if (event.type !== "session.created" && event.type !== "session.updated") return sessions
|
||||||
|
if (index === -1) return [...sessions, info]
|
||||||
|
return sessions.with(index, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLegacySummary(session: SessionV2Info): Session {
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
slug: session.id,
|
||||||
|
projectID: session.projectID,
|
||||||
|
workspaceID: session.location.workspaceID,
|
||||||
|
directory: session.location.directory,
|
||||||
|
path: session.subpath,
|
||||||
|
parentID: session.parentID,
|
||||||
|
cost: session.cost,
|
||||||
|
tokens: session.tokens,
|
||||||
|
title: session.title,
|
||||||
|
agent: session.agent,
|
||||||
|
model: session.model,
|
||||||
|
version: "",
|
||||||
|
time: session.time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,4 +31,24 @@ describe("toggleMcp", () => {
|
||||||
await toggleMcp(input("disabled"))
|
await toggleMcp(input("disabled"))
|
||||||
expect(calls).toEqual(["connect", "refresh"])
|
expect(calls).toEqual(["connect", "refresh"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("does not toggle a server while its connection is pending", async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
await toggleMcp({
|
||||||
|
status: "pending",
|
||||||
|
connect: async () => {
|
||||||
|
calls.push("connect")
|
||||||
|
},
|
||||||
|
disconnect: async () => {
|
||||||
|
calls.push("disconnect")
|
||||||
|
},
|
||||||
|
authenticate: async () => {
|
||||||
|
calls.push("authenticate")
|
||||||
|
},
|
||||||
|
refresh: async () => {
|
||||||
|
calls.push("refresh")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
|
import type { McpServer } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
export async function toggleMcp(input: {
|
export async function toggleMcp(input: {
|
||||||
status: McpStatus["status"]
|
status: McpServer["status"]["status"]
|
||||||
connect: () => Promise<void>
|
connect: () => Promise<void>
|
||||||
disconnect: () => Promise<void>
|
disconnect: () => Promise<void>
|
||||||
authenticate: () => Promise<void>
|
authenticate: () => Promise<void>
|
||||||
refresh: () => Promise<void>
|
refresh: () => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
|
if (input.status === "pending") return
|
||||||
await {
|
await {
|
||||||
connected: input.disconnect,
|
connected: input.disconnect,
|
||||||
needs_auth: input.authenticate,
|
needs_auth: input.authenticate,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type {
|
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
|
||||||
Message,
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
Part,
|
|
||||||
PermissionRequest,
|
|
||||||
QuestionRequest,
|
|
||||||
SessionStatus,
|
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
|
||||||
import type { SessionDiff } from "@/utils/diffs"
|
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||||
|
|
||||||
const msg = (id: string, sessionID: string) =>
|
const msg = (id: string, sessionID: string) =>
|
||||||
|
|
@ -32,8 +26,10 @@ describe("app session cache", () => {
|
||||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||||
const store: {
|
const store: {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, SessionDiff[] | undefined>
|
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||||
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
|
session_message: Record<string, never[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
|
|
@ -41,7 +37,9 @@ describe("app session cache", () => {
|
||||||
} = {
|
} = {
|
||||||
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
||||||
session_diff: { ses_1: [] },
|
session_diff: { ses_1: [] },
|
||||||
|
todo: { ses_1: [] as Todo[] },
|
||||||
message: {},
|
message: {},
|
||||||
|
session_message: {},
|
||||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||||
permission: { ses_1: [] as PermissionRequest[] },
|
permission: { ses_1: [] as PermissionRequest[] },
|
||||||
question: { ses_1: [] as QuestionRequest[] },
|
question: { ses_1: [] as QuestionRequest[] },
|
||||||
|
|
@ -53,6 +51,7 @@ describe("app session cache", () => {
|
||||||
expect(store.message.ses_1).toBeUndefined()
|
expect(store.message.ses_1).toBeUndefined()
|
||||||
expect(store.part.msg_1).toBeUndefined()
|
expect(store.part.msg_1).toBeUndefined()
|
||||||
expect(store.part_text_accum_delta.prt_1).toBeUndefined()
|
expect(store.part_text_accum_delta.prt_1).toBeUndefined()
|
||||||
|
expect(store.todo.ses_1).toBeUndefined()
|
||||||
expect(store.session_diff.ses_1).toBeUndefined()
|
expect(store.session_diff.ses_1).toBeUndefined()
|
||||||
expect(store.session_status.ses_1).toBeUndefined()
|
expect(store.session_status.ses_1).toBeUndefined()
|
||||||
expect(store.permission.ses_1).toBeUndefined()
|
expect(store.permission.ses_1).toBeUndefined()
|
||||||
|
|
@ -63,8 +62,10 @@ describe("app session cache", () => {
|
||||||
const m = msg("msg_1", "ses_1")
|
const m = msg("msg_1", "ses_1")
|
||||||
const store: {
|
const store: {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, SessionDiff[] | undefined>
|
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||||
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
|
session_message: Record<string, never[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
|
|
@ -72,7 +73,9 @@ describe("app session cache", () => {
|
||||||
} = {
|
} = {
|
||||||
session_status: {},
|
session_status: {},
|
||||||
session_diff: {},
|
session_diff: {},
|
||||||
|
todo: {},
|
||||||
message: { ses_1: [m] },
|
message: { ses_1: [m] },
|
||||||
|
session_message: {},
|
||||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
question: {},
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,15 @@
|
||||||
import type {
|
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
|
||||||
Message,
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
Part,
|
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
PermissionRequest,
|
|
||||||
QuestionRequest,
|
|
||||||
SessionStatus,
|
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
|
||||||
import type { SessionDiff } from "@/utils/diffs"
|
|
||||||
|
|
||||||
export const SESSION_CACHE_LIMIT = 40
|
export const SESSION_CACHE_LIMIT = 40
|
||||||
|
|
||||||
type SessionCache = {
|
type SessionCache = {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, SessionDiff[] | undefined>
|
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||||
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
|
session_message: Record<string, SessionMessageInfo[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
|
|
@ -34,6 +31,8 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
||||||
|
|
||||||
for (const sessionID of stale) {
|
for (const sessionID of stale) {
|
||||||
delete store.message[sessionID]
|
delete store.message[sessionID]
|
||||||
|
delete store.todo[sessionID]
|
||||||
|
delete store.session_message[sessionID]
|
||||||
delete store.session_diff[sessionID]
|
delete store.session_diff[sessionID]
|
||||||
delete store.session_status[sessionID]
|
delete store.session_status[sessionID]
|
||||||
delete store.permission[sessionID]
|
delete store.permission[sessionID]
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,28 @@
|
||||||
import type { RootLoadArgs } from "./types"
|
import type { SessionApi } from "@opencode-ai/client/promise"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
|
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
|
||||||
|
const result = await input.api.list({
|
||||||
|
directory: input.directory,
|
||||||
|
parentID: null,
|
||||||
|
limit: input.limit,
|
||||||
|
order: "desc",
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
data: result.data.map(normalizeSessionInfo),
|
||||||
|
limit: input.limit,
|
||||||
|
limited: true,
|
||||||
|
} as const
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
|
||||||
try {
|
try {
|
||||||
const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
|
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
|
||||||
return {
|
return { data: result.data, limit: input.limit, limited: true } as const
|
||||||
data: result.data,
|
|
||||||
limit: input.limit,
|
|
||||||
limited: true,
|
|
||||||
} as const
|
|
||||||
} catch {
|
} catch {
|
||||||
const result = await input.list({ directory: input.directory, roots: true })
|
const result = await input.client.session.list({ directory: input.directory, roots: true })
|
||||||
return {
|
return { data: result.data, limit: input.limit, limited: false } as const
|
||||||
data: result.data,
|
|
||||||
limit: input.limit,
|
|
||||||
limited: false,
|
|
||||||
} as const
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
Command,
|
|
||||||
Config,
|
Config,
|
||||||
LspStatus,
|
LspStatus,
|
||||||
McpResource,
|
|
||||||
McpStatus,
|
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
Path,
|
Path,
|
||||||
|
|
@ -13,10 +10,12 @@ import type {
|
||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
|
Todo,
|
||||||
VcsInfo,
|
VcsInfo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import type { SessionDiff } from "@/utils/diffs"
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
|
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||||
|
|
||||||
|
|
@ -34,7 +33,7 @@ export type ProjectMeta = {
|
||||||
export type State = {
|
export type State = {
|
||||||
status: "loading" | "partial" | "complete"
|
status: "loading" | "partial" | "complete"
|
||||||
agent: Agent[]
|
agent: Agent[]
|
||||||
command: Command[]
|
command: CommandInfo[]
|
||||||
reference: ReferenceInfo[]
|
reference: ReferenceInfo[]
|
||||||
project: string
|
project: string
|
||||||
projectMeta: ProjectMeta | undefined
|
projectMeta: ProjectMeta | undefined
|
||||||
|
|
@ -50,7 +49,10 @@ export type State = {
|
||||||
}
|
}
|
||||||
session_working(id: string): boolean
|
session_working(id: string): boolean
|
||||||
session_diff: {
|
session_diff: {
|
||||||
[sessionID: string]: SessionDiff[]
|
[sessionID: string]: FileDiffInfo[]
|
||||||
|
}
|
||||||
|
todo: {
|
||||||
|
[sessionID: string]: Todo[]
|
||||||
}
|
}
|
||||||
permission: {
|
permission: {
|
||||||
[sessionID: string]: PermissionRequest[]
|
[sessionID: string]: PermissionRequest[]
|
||||||
|
|
@ -60,7 +62,7 @@ export type State = {
|
||||||
}
|
}
|
||||||
mcp_ready: boolean
|
mcp_ready: boolean
|
||||||
mcp: {
|
mcp: {
|
||||||
[name: string]: McpStatus
|
[name: string]: McpServer["status"]
|
||||||
}
|
}
|
||||||
mcp_resource: {
|
mcp_resource: {
|
||||||
[key: string]: McpResource
|
[key: string]: McpResource
|
||||||
|
|
@ -72,6 +74,9 @@ export type State = {
|
||||||
message: {
|
message: {
|
||||||
[sessionID: string]: Message[]
|
[sessionID: string]: Message[]
|
||||||
}
|
}
|
||||||
|
session_message: {
|
||||||
|
[sessionID: string]: SessionMessageInfo[]
|
||||||
|
}
|
||||||
part: {
|
part: {
|
||||||
[messageID: string]: Part[]
|
[messageID: string]: Part[]
|
||||||
}
|
}
|
||||||
|
|
@ -124,18 +129,6 @@ export type DisposeCheck = {
|
||||||
loadingSessions: boolean
|
loadingSessions: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RootLoadArgs = {
|
|
||||||
directory: string
|
|
||||||
limit: number
|
|
||||||
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RootLoadResult = {
|
|
||||||
data?: Session[]
|
|
||||||
limit: number
|
|
||||||
limited: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MAX_DIR_STORES = 30
|
export const MAX_DIR_STORES = 30
|
||||||
export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
|
export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
|
||||||
export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
|
export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,117 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Agent } from "@opencode-ai/sdk/v2/client"
|
import type {
|
||||||
import { directoryKey, normalizeAgentList } from "./utils"
|
AgentListOutput,
|
||||||
|
ModelDefaultOutput,
|
||||||
const agent = (name = "build") =>
|
ModelListOutput,
|
||||||
({
|
ProviderListOutput,
|
||||||
name,
|
} from "@opencode-ai/client/promise"
|
||||||
mode: "primary",
|
import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
|
||||||
permission: {},
|
|
||||||
options: {},
|
|
||||||
}) as Agent
|
|
||||||
|
|
||||||
describe("normalizeAgentList", () => {
|
describe("normalizeAgentList", () => {
|
||||||
test("keeps array payloads", () => {
|
test("adapts current agents to the app agent shape", () => {
|
||||||
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
|
const result = normalizeAgentList([
|
||||||
})
|
{
|
||||||
|
id: "build",
|
||||||
|
name: "Build",
|
||||||
|
mode: "primary",
|
||||||
|
hidden: false,
|
||||||
|
color: "primary",
|
||||||
|
model: { id: "gpt-5", providerID: "openai", variant: "high" },
|
||||||
|
request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} },
|
||||||
|
system: "Build software",
|
||||||
|
permissions: [{ action: "read", resource: "*", effect: "allow" }],
|
||||||
|
},
|
||||||
|
] as AgentListOutput["data"])
|
||||||
|
|
||||||
test("wraps a single agent payload", () => {
|
expect(result).toEqual([
|
||||||
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
|
{
|
||||||
|
name: "build",
|
||||||
|
description: undefined,
|
||||||
|
mode: "primary",
|
||||||
|
hidden: false,
|
||||||
|
temperature: 0.2,
|
||||||
|
topP: 0.9,
|
||||||
|
color: "primary",
|
||||||
|
permission: [{ permission: "read", pattern: "*", action: "allow" }],
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5" },
|
||||||
|
variant: "high",
|
||||||
|
prompt: "Build software",
|
||||||
|
options: { temperature: 0.2, topP: 0.9 },
|
||||||
|
steps: undefined,
|
||||||
|
},
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("extracts agents from keyed objects", () => {
|
describe("normalizePermissionRequest", () => {
|
||||||
|
test("adapts the current permission request to app state", () => {
|
||||||
expect(
|
expect(
|
||||||
normalizeAgentList({
|
normalizePermissionRequest({
|
||||||
build: agent("build"),
|
id: "permission-1",
|
||||||
docs: agent("docs"),
|
sessionID: "session-1",
|
||||||
|
action: "read",
|
||||||
|
resources: ["README.md"],
|
||||||
|
save: ["*.md"],
|
||||||
|
metadata: { path: "README.md" },
|
||||||
|
source: { type: "tool", messageID: "message-1", callID: "call-1" },
|
||||||
}),
|
}),
|
||||||
).toEqual([agent("build"), agent("docs")])
|
).toEqual({
|
||||||
|
id: "permission-1",
|
||||||
|
sessionID: "session-1",
|
||||||
|
permission: "read",
|
||||||
|
patterns: ["README.md"],
|
||||||
|
always: ["*.md"],
|
||||||
|
metadata: { path: "README.md" },
|
||||||
|
tool: { messageID: "message-1", callID: "call-1" },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("drops invalid payloads", () => {
|
describe("normalizeProviderList", () => {
|
||||||
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
|
test("groups current models into the app provider catalog", () => {
|
||||||
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
|
const result = normalizeProviderList(
|
||||||
|
[{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "gpt-5",
|
||||||
|
modelID: "gpt-5",
|
||||||
|
providerID: "openai",
|
||||||
|
name: "GPT-5",
|
||||||
|
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||||
|
variants: [{ id: "high" }],
|
||||||
|
time: { released: 1 },
|
||||||
|
cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }],
|
||||||
|
status: "active",
|
||||||
|
enabled: true,
|
||||||
|
limit: { context: 128_000, output: 8_192 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-old",
|
||||||
|
modelID: "gpt-old",
|
||||||
|
providerID: "openai",
|
||||||
|
name: "GPT Old",
|
||||||
|
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
||||||
|
variants: [],
|
||||||
|
time: { released: 0 },
|
||||||
|
cost: [],
|
||||||
|
status: "deprecated",
|
||||||
|
enabled: true,
|
||||||
|
limit: { context: 1, output: 1 },
|
||||||
|
},
|
||||||
|
] as ModelListOutput["data"],
|
||||||
|
{ id: "gpt-5", providerID: "openai" } as ModelDefaultOutput["data"],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.connected).toEqual(["openai"])
|
||||||
|
expect(result.default).toEqual({ openai: "gpt-5" })
|
||||||
|
expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined()
|
||||||
|
expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({
|
||||||
|
id: "gpt-5",
|
||||||
|
providerID: "openai",
|
||||||
|
capabilities: { toolcall: true, attachment: true },
|
||||||
|
cost: { input: 1, output: 2 },
|
||||||
|
variants: { high: {} },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue