Compare commits

...
53 Commits
Author SHA1 Message Date
rekram1-node ad5a03895c test(sdk): check packed server status 2026-09-14 01:35:49 +00:00
rekram1-node 34afa46e23 fix(server): await plugin-backed reads 2026-09-14 01:27:01 +00:00
Dax Raad aeed4b6375 fix(protocol): return missing integration error 2026-09-13 20:00:46 -04:00
Dax Raad 82f713421f fix(client): replace servers missing status endpoint 2026-09-13 19:59:12 -04:00
Dax Raad 763cac8080 feat(config): add experimental updates 2026-09-13 19:41:06 -04:00
Dax Raad c2cf497e19 refactor(config): remove preferences API 2026-09-13 19:27:56 -04:00
Dax Raad 7784b3ee0d refactor(core): isolate compatibility skills 2026-09-13 19:11:13 -04:00
Dax Raad 199aabe9e2 refactor(protocol): simplify skill and reference contracts 2026-09-13 18:52:55 -04:00
Dax Raad 8905af5074 refactor(protocol): remove plugin activation wait 2026-09-13 18:03:18 -04:00
Dax Raad ce56111a4c refactor(protocol): simplify response locations 2026-09-13 17:52:45 -04:00
Dax Raad 5ab0167288 refactor(schema): remove project initialized time 2026-09-13 17:44:59 -04:00
Dax Raad 487e1bd76e refactor(protocol): remove workspace API 2026-09-13 17:39:23 -04:00
Dax 1eeaaab7a6 refactor: restrict reflective access (#48860) 2026-09-13 17:08:43 -04:00
Aiden Cline b901cb28af fix(ai): omit OpenAI tool choice when tools are empty (#48857) 2026-09-13 16:01:59 -05:00
Dax Raad 13453f2da8 refactor(protocol): consolidate server status 2026-09-13 16:56:22 -04:00
Dax Raad e16c56fe8b refactor(protocol): remove v2 operation prefixes 2026-09-13 16:31:46 -04:00
Dax Raad 42ff564913 refactor(protocol): remove current project endpoint 2026-09-13 16:20:43 -04:00
Aiden Cline 195158c34c fix(ai): normalize Bedrock Mistral tool IDs (#48843) 2026-09-13 12:27:04 -05:00
Aiden Cline 7a31b5c0f7 fix(ai): normalize Bedrock document labels (#48756) 2026-09-12 23:43:25 -05:00
Aiden Cline fb3c10ca66 fix(ai): sanitize replayed Bedrock tool names (#48750) 2026-09-12 23:16:02 -05:00
Aiden Cline c43cfccc4e fix(core): shape compaction and generate requests with built-in context hooks (#48749) 2026-09-12 23:10:09 -05:00
opencode c5aa7d7e34 sync release versions for v2.0.3 2026-09-12 23:48:02 +00:00
Dax Raad 9c8a4ea4ff fix(cli): replace update preflight renderer 2026-09-12 18:56:37 -04:00
Dax Raad c82340a97b fix(tui): probe palette only for system theme 2026-09-12 18:16:21 -04:00
Dax Raad dbc63955b0 refactor(tui): simplify theme palette lifecycle 2026-09-12 17:50:32 -04:00
Shoubhit DashandDax Raad 2816d1c849 feat(session): add turn diff route (#47821)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-09-12 21:26:52 +00:00
Dax Raad 61d812fcd6 fix(tui): refresh system palette after paint 2026-09-12 17:25:44 -04:00
Dax Raad e47b9b5453 fix(tui): cache system theme palette 2026-09-12 17:24:04 -04:00
Dax Raad 21dac524f3 fix(tui): defer palette probe until first frame 2026-09-12 17:20:09 -04:00
Dax Raad 3dde11c392 chore(tui): log stalled plugin startup 2026-09-12 16:43:59 -04:00
opencode-agent[bot] 0643a5638e chore: update nix node_modules hashes 2026-09-12 19:41:39 +00:00
Luke Parker 81523d4a84 fix(app): stabilize mobile timeline touch scrolling (#48600) 2026-09-13 05:24:23 +10:00
opencode 7c5a4d01aa sync release versions for v2.0.2 2026-09-12 07:57:20 +00:00
David Hill 4143084250 feat(app): polish summary panels and config actions (#48445) 2026-09-12 17:42:04 +10:00
opencode-agent[bot] c7868ce0e6 chore: update nix node_modules hashes 2026-09-12 07:28:31 +00:00
Dax Raad cf4f1fb45e fix(tui): remove preview session tabs 2026-09-12 03:22:32 -04:00
Aiden Cline 0a21042523 fix(tools): clarify unavailable tool errors (#48620) 2026-09-12 02:20:04 -05:00
Aiden Cline 6add96698e refactor(codemode): move built-ins onto real prototypes with native function objects (#48608) 2026-09-12 02:19:30 -05:00
opencode-agent[bot]andrekram1-node 3f579f53f4 feat(core): add TinyFish web search (#48561)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-12 02:10:54 -05:00
David Hill 9f3ba44c4a fix(app): keep summaries clear of the session timeline (#48449) 2026-09-12 17:07:55 +10:00
Luke Parker c4fe0f676a fix(app): polyfill startup APIs for older Safari (#48621) 2026-09-12 17:07:33 +10:00
Brendan AllanandBrendonovich 625c469854 fix(server): return 404 for missing files (#44089)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-09-12 14:55:10 +08:00
usrnk1andLukeParkerDev d6c22b3bf5 feat(desktop): consolidate status into session summary (#48103)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-12 06:09:16 +00:00
bbf4cd4975 fix(desktop): polish settings search (#48210)
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-12 05:27:23 +00:00
Dax Raad 27027777d2 docs: expand V1 plugin migration guide 2026-09-12 01:12:27 -04:00
usrnk1andLukeParkerDev 5d1841e0aa feat(desktop): redesign settings with search (#48174)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-12 05:05:48 +00:00
opencode 2253d4c31d sync release versions for v2.0.1 2026-09-12 05:01:52 +00:00
usrnk1andLukeParkerDev 877b04f0b9 feat(desktop): redesign settings navigation and project editing (#47983)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-12 04:36:17 +00:00
Luke Parker 41e5d1b6b6 fix(session-ui): keep divider notices out of used groups (#48594) 2026-09-12 03:58:18 +00:00
Luke Parker 60fb97d59e fix(desktop): preserve keyboard focus during browser navigation (#48593) 2026-09-12 03:54:22 +00:00
Aiden Cline b2e6e764a8 refactor(codemode): give errors a real prototype chain and JS error types (#48559) 2026-09-11 21:45:29 -05:00
Aiden Cline 0d8e64112f fix(tui): defer named-theme palette detection (#48570) 2026-09-11 21:42:47 -05:00
Aiden Cline b0b85cbb77 fix(ai): omit empty Bedrock tool descriptions (#48582) 2026-09-11 21:18:57 -05:00
537 changed files with 20260 additions and 38061 deletions
@@ -1,4 +1,4 @@
name: typecheck
name: check
on:
push:
@@ -8,7 +8,7 @@ on:
workflow_dispatch:
jobs:
typecheck:
check:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
@@ -17,5 +17,5 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Run typecheck
run: bun typecheck
- name: Run checks
run: bun run check
+1 -1
View File
@@ -17,4 +17,4 @@ if (process.versions.bun !== expectedBunVersion) {
console.warn(`Warning: Bun version ${process.versions.bun} differs from expected ${expectedBunVersion}`);
}
'
bun typecheck
bun run check
+14 -37
View File
@@ -1,45 +1,22 @@
{
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
"options": {
"typeAware": true
},
"categories": {
"suspicious": "warn"
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"style": "off",
"restriction": "off",
"nursery": "off"
},
"rules": {
"typescript/no-base-to-string": "warn",
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
"require-yield": "off",
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
"no-unassigned-vars": "off",
// SolidJS tracks reactive deps by reading properties inside createEffect
"no-unused-expressions": "off",
// Intentional control char matching (ANSI escapes, null byte sanitization)
"no-control-regex": "off",
// SST and plugin tools require triple-slash references
"triple-slash-reference": "off",
// Suspicious category: suppress noisy rules
// Effect's nested function* closures inherently shadow outer scope
"no-shadow": "off",
// Namespace-heavy codebase makes this too noisy
"unicorn/consistent-function-scoping": "off",
// Opinionated — .sort()/.reverse() mutation is fine in this codebase
"unicorn/no-array-sort": "off",
"unicorn/no-array-reverse": "off",
// Not relevant — this isn't a DOM event handler codebase
"unicorn/prefer-add-event-listener": "off",
// Bundler handles module resolution
"unicorn/require-module-specifiers": "off",
// postMessage target origin not relevant for this codebase
"unicorn/require-post-message-target-origin": "off",
// Side-effectful constructors are intentional in some places
"no-new": "off",
// Type-aware: catch unhandled promises
"typescript/no-floating-promises": "warn",
// Warn when spreading non-plain objects (Headers, class instances, etc.)
"typescript/no-misused-spread": "warn"
"no-restricted-globals": [
"error",
{
"name": "Reflect",
"message": "Use typed property access or direct invocation. Suppress this rule only for genuine reflection."
}
]
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
}
+3 -2
View File
@@ -170,9 +170,10 @@ const table = sqliteTable("session", {
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package directories such as `packages/core`.
## Type Checking
## Checks
- Always run `bun typecheck` from package directories (for example, `packages/core`), never `tsc` directly.
- Run `bun run check` from the repository root as the canonical full lint and type-check verification.
- During focused iteration, run `bun typecheck` from the affected package directory (for example, `packages/core`). Never run `tsc` directly.
## V2 Session Core
+279
View File
@@ -0,0 +1,279 @@
# V2 HTTP API audit checklist
**Source:** `packages/protocol/openapi.json`
**Current endpoint count:** 139
**Last regenerated:** 2026-09-13
## How to use this checklist
Review endpoints in document order. For each endpoint, select one disposition and capture rationale or follow-up work in Notes. Mark **Reviewed** only after the disposition is agreed.
### Review criteria
- Resource and operation naming
- HTTP method and idempotency
- Request parameters and location scope
- Response shape and error taxonomy
- Authentication and authorization
- Current production consumers
- Stability level: public, experimental, or internal
- Whether the generated client API is intuitive
### Disposition legend
- **Keep:** ship unchanged as a supported V2 API
- **Change:** retain after a defined contract change
- **Remove:** exclude from the official V2 API
- **Experimental-only:** retain outside the stable API commitment
## Progress
- [x] Group 1: Foundation and placement (4)
- [ ] Group 2: Configuration and capability catalogs (16)
- [ ] Group 3: Credentials, integrations, MCP, and web search (22)
- [ ] Group 4: Session lifecycle (12)
- [ ] Group 5: Session execution and inputs (11)
- [ ] Group 6: Session history and recovery (13)
- [ ] Group 7: Inbox, permissions, and forms (19)
- [ ] Group 8: Filesystem, worktrees, and VCS (12)
- [ ] Group 9: PTYs, persistent terminals, and shells (24)
- [ ] Group 10: Events, RPC, and experimental operations (6)
## Resolved during audit
### [x] `POST /api/plugin/await-activation`
- **Decision:** Remove
- **Notes:** Activation timing is an internal server concern. Catalog reads remain non-blocking.
### [x] Location response wrappers
- **Decision:** Reduce generic endpoint response locations to `{ directory }`.
- **Notes:** Full project metadata remains available from `GET /api/location`; no consumers used it from wrapped responses.
### [x] `GET /api/health` and `GET /api/server`
- **Decision:** Merge and rename
- **Replacement:** `GET /api/status` with operation ID `server.status`.
- **Notes:** Returns `version`, `pid`, and connection `urls`; readiness is conveyed by HTTP status.
### [x] `GET /api/project/current`
- **Decision:** Remove
- **Replacement:** `GET /api/location`, using `project` from the response.
- **Notes:** The endpoint duplicated `Location.Info.project`; production callers were migrated.
### [x] `POST /api/workspace` and `DELETE /api/workspace/{workspaceID}`
- **Decision:** Remove
- **Notes:** Provider-backed workspaces are not part of the V2 HTTP contract and can be introduced later. Core and the embedded SDK retain internal workspace support.
## Group 1: Foundation and placement
**Endpoints:** 4
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 001002 | `GET` | `/api/status` | `server.status` | Keep | Replaces the former health and server endpoints. |
| [x] 003 | `GET` | `/api/location` | `location.get` | Keep | Workspace selectors and response fields removed until workspace support ships. |
| [x] 004 | `GET` | `/api/project` | `project.list` | Keep | Removed unused `time.initialized`; the database column remains for migration data. |
| [x] 005 | `PATCH` | `/api/project/{projectID}` | `project.update` | Keep | Request and response accepted as-is. |
## Group 2: Configuration and capability catalogs
**Endpoints:** 16
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 008 | `GET` | `/api/agent` | `agent.list` | Keep | Request and response accepted as-is. |
| [x] 009 | `GET` | `/api/agent/{agentID}` | `agent.get` | Keep | Request, response, and not-found error accepted as-is. |
| [x] 010 | `GET` | `/api/plugin` | `plugin.list` | Keep | Request and response accepted as-is. |
| [x] 012 | `POST` | `/api/plugin/check` | `plugin.check` | Keep | Request and response accepted as-is. |
| [x] 013 | `POST` | `/api/plugin/update` | `plugin.update` | Keep | Request and errors accepted as-is. |
| [x] 014 | `GET` | `/api/model` | `model.list` | Keep | Request and response accepted as-is. |
| [x] 015 | `GET` | `/api/model/default` | `model.default` | Keep | Request and nullable response accepted as-is. |
| [x] 016 | `GET` | `/api/provider` | `provider.list` | Keep | Request and response accepted as-is. |
| [x] 017 | `GET` | `/api/provider/{providerID}` | `provider.get` | Keep | Request, response, and not-found error accepted as-is. |
| [x] 018 | `GET` | `/api/command` | `command.list` | Keep | Request and response accepted as-is. |
| [x] 019 | `GET` | `/api/skill` | `skill.list` | Keep | Renamed `location` to `path`; removed the skill-specific `slash` flag and slash-command behavior. |
| [x] 020 | `GET` | `/api/reference` | `reference.list` | Keep | Removed duplicate `description` and `hidden` fields from nested `source`. |
| [x] 021 | `GET` | `/api/config` | `config.get` | Keep | Compatibility entries removed; response now contains only documents and OpenCode directories. |
| [x] 022 | `GET` | `/api/config/preferences` | `config.preferences` | Remove | Redundant special projection of global config. |
| [x] 023 | `PATCH` | `/api/config/preferences` | `config.updatePreferences` | Remove | Redundant field-specific config mutation API. |
| [ ] 024 | `GET` | `/api/config/shell` | `config.shells` | | |
| [x] 024a | `PATCH` | `/api/experimental/config` | `experimental.config.update` | Change | Experimental global config mutation; initially accepts only `shell`. |
## Group 3: Credentials, integrations, MCP, and web search
**Endpoints:** 22
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 025 | `GET` | `/api/integration` | `integration.list` | Keep | Full integration inventory is consumed by authentication and integration-selection clients. |
| [x] 026 | `GET` | `/api/integration/{integrationID}` | `integration.get` | Change | Missing integration now returns typed `404` instead of optional data. |
| [ ] 027 | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | | |
| [ ] 028 | `POST` | `/api/integration/{integrationID}/connect/key` | `integration.connect.key` | | |
| [ ] 029 | `POST` | `/api/integration/{integrationID}/connect/oauth` | `integration.oauth.connect` | | |
| [ ] 030 | `GET` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.status` | | |
| [ ] 031 | `DELETE` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.cancel` | | |
| [ ] 032 | `POST` | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | `integration.oauth.complete` | | |
| [ ] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | | |
| [ ] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | | |
| [ ] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | | |
| [ ] 036 | `GET` | `/api/mcp` | `mcp.list` | | |
| [ ] 037 | `PUT` | `/api/mcp/{server}` | `mcp.add` | | |
| [ ] 038 | `DELETE` | `/api/mcp/{server}` | `mcp.remove` | | |
| [ ] 039 | `POST` | `/api/mcp/{server}/connect` | `mcp.connect` | | |
| [ ] 040 | `POST` | `/api/mcp/{server}/disconnect` | `mcp.disconnect` | | |
| [ ] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | | |
| [ ] 042 | `PATCH` | `/api/credential/{credentialID}` | `credential.update` | | |
| [ ] 043 | `DELETE` | `/api/credential/{credentialID}` | `credential.remove` | | |
| [ ] 044 | `POST` | `/api/credential/{credentialID}/activate` | `credential.activate` | | |
| [ ] 045 | `GET` | `/api/websearch/provider` | `websearch.providers` | | |
| [ ] 046 | `POST` | `/api/websearch` | `websearch.query` | | |
## Group 4: Session lifecycle
**Endpoints:** 12
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 047 | `GET` | `/api/session` | `session.list` | | |
| [ ] 048 | `POST` | `/api/session` | `session.create` | | |
| [ ] 049 | `GET` | `/api/session/stats` | `session.stats` | | |
| [ ] 050 | `GET` | `/api/session/active` | `session.active` | | |
| [ ] 051 | `GET` | `/api/session/{sessionID}` | `session.get` | | |
| [ ] 052 | `DELETE` | `/api/session/{sessionID}` | `session.remove` | | |
| [ ] 053 | `POST` | `/api/session/{sessionID}/fork` | `session.fork` | | |
| [ ] 054 | `POST` | `/api/session/{sessionID}/agent` | `session.switchAgent` | | |
| [ ] 055 | `POST` | `/api/session/{sessionID}/model` | `session.switchModel` | | |
| [ ] 056 | `POST` | `/api/session/{sessionID}/rename` | `session.rename` | | |
| [ ] 057 | `POST` | `/api/session/{sessionID}/move` | `session.move` | | |
| [ ] 058 | `POST` | `/api/session/{sessionID}/background` | `session.background` | | |
## Group 5: Session execution and inputs
**Endpoints:** 11
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 059 | `POST` | `/api/session/{sessionID}/prompt` | `session.prompt` | | |
| [ ] 060 | `POST` | `/api/session/{sessionID}/command` | `session.command` | | |
| [ ] 061 | `POST` | `/api/session/{sessionID}/skill` | `session.skill` | | |
| [ ] 062 | `POST` | `/api/session/{sessionID}/synthetic` | `session.synthetic` | | |
| [ ] 063 | `POST` | `/api/session/{sessionID}/shell` | `session.shell` | | |
| [ ] 064 | `POST` | `/api/session/{sessionID}/compact` | `session.compact` | | |
| [ ] 065 | `POST` | `/api/session/{sessionID}/wait` | `session.wait` | | |
| [ ] 066 | `POST` | `/api/session/{sessionID}/generate` | `session.generate` | | |
| [ ] 067 | `POST` | `/api/session/{sessionID}/interrupt` | `session.interrupt` | | |
| [ ] 068 | `PUT` | `/api/session/{sessionID}/environment` | `session.environment` | | |
| [ ] 069 | `POST` | `/api/session/{sessionID}/view` | `session.view` | | |
## Group 6: Session history and recovery
**Endpoints:** 13
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 070 | `POST` | `/api/session/import` | `session.import` | | |
| [ ] 071 | `GET` | `/api/session/{sessionID}/export` | `session.export` | | |
| [ ] 072 | `POST` | `/api/session/{sessionID}/revert/stage` | `session.revert.stage` | | |
| [ ] 073 | `POST` | `/api/session/{sessionID}/revert/clear` | `session.revert.clear` | | |
| [ ] 074 | `POST` | `/api/session/{sessionID}/revert/commit` | `session.revert.commit` | | |
| [ ] 075 | `GET` | `/api/session/{sessionID}/context` | `session.context` | | |
| [ ] 076 | `GET` | `/api/session/{sessionID}/diff` | `session.diff` | | |
| [ ] 077 | `GET` | `/api/session/{sessionID}/instructions/entries` | `session.instructions.entry.list` | | |
| [ ] 078 | `PUT` | `/api/session/{sessionID}/instructions/entries/{key}` | `session.instructions.entry.put` | | |
| [ ] 079 | `DELETE` | `/api/session/{sessionID}/instructions/entries/{key}` | `session.instructions.entry.remove` | | |
| [ ] 080 | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | | |
| [ ] 081 | `GET` | `/api/session/{sessionID}/message/{messageID}` | `session.message` | | |
| [ ] 082 | `GET` | `/api/session/{sessionID}/message` | `message.list` | | |
## Group 7: Inbox, permissions, and forms
**Endpoints:** 19
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 083 | `GET` | `/api/session/{sessionID}/inbox` | `session.inbox.list` | | |
| [ ] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | | |
| [ ] 085 | `POST` | `/api/session/{sessionID}/inbox/{inboxID}/steer` | `session.inbox.steer` | | |
| [ ] 086 | `POST` | `/api/session/{sessionID}/inbox/{inboxID}/queue` | `session.inbox.queue` | | |
| [ ] 087 | `GET` | `/api/form/request` | `form.request.list` | | |
| [ ] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | | |
| [ ] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | | |
| [ ] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | | |
| [ ] 091 | `GET` | `/api/session/{sessionID}/form/{formID}/state` | `session.form.state` | | |
| [ ] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | | |
| [ ] 093 | `POST` | `/api/session/{sessionID}/form/{formID}/cancel` | `session.form.cancel` | | |
| [ ] 094 | `GET` | `/api/permission/request` | `permission.request.list` | | |
| [ ] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | | |
| [ ] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | | |
| [ ] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | | |
| [ ] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | | |
| [ ] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | | |
| [ ] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | | |
| [ ] 101 | `PUT` | `/api/session/{sessionID}/permission/rules` | `session.permission.rules` | | |
## Group 8: Filesystem, worktrees, and VCS
**Endpoints:** 12
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 102 | `GET` | `/api/fs/read/*` | `fs.read` | | |
| [ ] 103 | `GET` | `/api/fs/list` | `fs.list` | | |
| [ ] 104 | `GET` | `/api/fs/find` | `fs.find` | | |
| [ ] 105 | `GET` | `/api/worktree` | `worktree.list` | | |
| [ ] 106 | `POST` | `/api/worktree` | `worktree.create` | | |
| [ ] 107 | `DELETE` | `/api/worktree` | `worktree.remove` | | |
| [ ] 108 | `POST` | `/api/worktree/refresh` | `worktree.refresh` | | |
| [ ] 109 | `GET` | `/api/vcs` | `vcs.get` | | |
| [ ] 110 | `GET` | `/api/vcs/base` | `vcs.base` | | |
| [ ] 111 | `GET` | `/api/vcs/status` | `vcs.status` | | |
| [ ] 112 | `GET` | `/api/vcs/branches` | `vcs.branches` | | |
| [ ] 113 | `GET` | `/api/vcs/diff` | `vcs.diff` | | |
## Group 9: PTYs, persistent terminals, and shells
**Endpoints:** 24
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 114 | `GET` | `/api/pty` | `pty.list` | | |
| [ ] 115 | `POST` | `/api/pty` | `pty.create` | | |
| [ ] 116 | `GET` | `/api/pty/{ptyID}` | `pty.get` | | |
| [ ] 117 | `PUT` | `/api/pty/{ptyID}` | `pty.update` | | |
| [ ] 118 | `DELETE` | `/api/pty/{ptyID}` | `pty.remove` | | |
| [ ] 119 | `POST` | `/api/pty/{ptyID}/connect-token` | `pty.connect.token` | | |
| [ ] 120 | `GET` | `/api/pty/{ptyID}/connect` | `pty.connect` | | |
| [ ] 121 | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | | |
| [ ] 122 | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | | |
| [ ] 123 | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | | |
| [ ] 124 | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | | |
| [ ] 125 | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | | |
| [ ] 126 | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | | |
| [ ] 127 | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | | |
| [ ] 128 | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | | |
| [ ] 129 | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | | |
| [ ] 130 | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | | |
| [ ] 131 | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | | |
| [ ] 132 | `GET` | `/api/shell` | `shell.list` | | |
| [ ] 133 | `POST` | `/api/shell` | `shell.create` | | |
| [ ] 134 | `GET` | `/api/shell/{id}` | `shell.get` | | |
| [ ] 135 | `DELETE` | `/api/shell/{id}` | `shell.remove` | | |
| [ ] 136 | `PATCH` | `/api/shell/{id}/timeout` | `shell.timeout` | | |
| [ ] 137 | `GET` | `/api/shell/{id}/output` | `shell.output` | | |
## Group 10: Events, RPC, and experimental operations
**Endpoints:** 6
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 138 | `POST` | `/api/generate` | `generate.text` | | |
| [ ] 139 | `POST` | `/api/rpc/{rpcID}/{method}` | `rpc.call` | | |
| [ ] 140 | `GET` | `/api/event` | `event.subscribe` | | |
| [ ] 141 | `GET` | `/api/debug/location` | `debug.location.list` | | |
| [ ] 142 | `DELETE` | `/api/debug/location` | `debug.location.evict` | | |
| [ ] 143 | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | | |
+38 -35
View File
@@ -31,7 +31,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -53,7 +53,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -82,6 +82,7 @@
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"core-js": "3.50.0",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
@@ -111,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.0",
"version": "2.0.3",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -175,7 +176,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -201,7 +202,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -215,7 +216,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -251,7 +252,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -278,7 +279,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -295,7 +296,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -319,7 +320,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -339,7 +340,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.82",
@@ -411,7 +412,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -463,7 +464,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -500,7 +501,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -516,7 +517,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -535,7 +536,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -548,7 +549,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -562,7 +563,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -577,7 +578,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -616,7 +617,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -646,7 +647,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -661,7 +662,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -685,7 +686,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -706,7 +707,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -728,7 +729,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -763,7 +764,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -783,7 +784,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -817,7 +818,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -836,7 +837,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -882,7 +883,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -896,7 +897,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -931,7 +932,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -966,7 +967,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -999,7 +1000,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1040,7 +1041,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.0",
"version": "2.0.3",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -3702,6 +3703,8 @@
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"core-js": ["core-js@3.50.0", "", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="],
"core-js-compat": ["core-js-compat@3.50.0", "", { "dependencies": { "browserslist": "^4.28.7" } }, "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-yzCk746pospz8EVakHRcDhYJkhGYGSt9dHOPbzO4OYo=",
"aarch64-linux": "sha256-MFJVLos4v2r9jazmmr3ldVCJKLrO+Qp/BpGpyM/1lf8=",
"aarch64-darwin": "sha256-k8r/HVSgdRSTlJ1lI7EebqbxeA3AElnaw1sDYOPEdQw=",
"x86_64-darwin": "sha256-ACJdJfz12xLBQvWIkbuve86znSoGJ2PLDQbgh0cT6/g="
"x86_64-linux": "sha256-euVUyj0CzjCA1nYbN2vKctEPzLkUlNGTK2dMNbackqM=",
"aarch64-linux": "sha256-qQkjqaxpjAae+rohoWI601QnrgKYghJ+ttqeiQBTwCM=",
"aarch64-darwin": "sha256-HYWs31TJlDZsDBNmbPARo16r7zNKy9x840uHGcUMYsk=",
"x86_64-darwin": "sha256-89FOrX813FENk3u8RAHCfyD7voaZWW++Z4Gpa3SkOJs="
}
}
+2 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.0",
"version": "2.0.3",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
@@ -24,6 +24,7 @@
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
"test:effect-simplification-rules": "ast-grep test -c script/ast-grep/effect-simplifications/sgconfig.yml",
"typecheck": "bun turbo typecheck --concurrency=3",
"check": "bun run lint && bun run typecheck",
"typecheck:profile": "bun script/profile-typecheck.ts",
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.0",
"version": "2.0.3",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+34 -19
View File
@@ -25,6 +25,7 @@ import { BedrockAuth } from "./utils/bedrock-auth.js"
import { BedrockCache } from "./utils/bedrock-cache.js"
import { BedrockMedia } from "./utils/bedrock-media.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { MistralToolID } from "./utils/mistral-tool-id.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
@@ -106,7 +107,7 @@ type BedrockSystemBlock = Schema.Schema.Type<typeof BedrockSystemBlock>
const BedrockToolSpec = Schema.Struct({
toolSpec: Schema.Struct({
name: Schema.String,
description: Schema.String,
description: Schema.optional(Schema.String),
inputSchema: Schema.Struct({
json: JsonObject,
}),
@@ -222,7 +223,7 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({
toolSpec: {
name: tool.name,
description: tool.description,
...(tool.description.trim().length > 0 ? { description: tool.description } : {}),
inputSchema: { json: inputSchema },
},
})
@@ -279,15 +280,19 @@ const removeEmptyToolInputKeys = (input: unknown): unknown => {
)
}
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string): BedrockToolUseBlock => ({
toolUse: {
toolUseId: part.id,
name: part.name,
toolUseId: normalizeID(part.id),
// Models can emit names that Converse rejects when replayed in history.
name: part.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "_",
input: removeEmptyToolInputKeys(part.input),
},
})
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (part: ToolResultPart) {
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (
part: ToolResultPart,
documentNames: Set<string>,
) {
if (part.result.type === "text" || part.result.type === "error")
return [{ text: ProviderShared.toolResultText(part) }]
if (part.result.type === "json") return [{ json: part.result.value }]
@@ -298,22 +303,29 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
content.push({ text: item.text })
continue
}
const media = yield* BedrockMedia.lower({
type: "media",
mediaType: item.mime,
data: item.uri,
filename: item.name,
})
content.push(media)
const media = yield* BedrockMedia.lower(
{
type: "media",
mediaType: item.mime,
data: item.uri,
filename: item.name,
},
documentNames,
)
content.push(...media)
}
return content
})
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (part: ToolResultPart) {
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (
part: ToolResultPart,
documentNames: Set<string>,
normalizeID: (id: string) => string,
) {
return {
toolResult: {
toolUseId: part.id,
content: yield* lowerToolResultContent(part),
toolUseId: normalizeID(part.id),
content: yield* lowerToolResultContent(part, documentNames),
status: part.result.type === "error" ? "error" : "success",
},
} satisfies BedrockToolResultBlock
@@ -324,6 +336,9 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
breakpoints: BedrockCache.Breakpoints,
) {
const messages: BedrockMessage[] = []
const documentNames = new Set<string>()
// Mistral can reject replay IDs even when they satisfy Converse's broader ID syntax.
const normalizeID = request.model.id.includes("mistral.") ? MistralToolID.normalizer(request) : (id: string) => id
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
for (const message of request.messages) {
@@ -347,7 +362,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
if (part.type === "media") {
content.push(yield* BedrockMedia.lower(part))
content.push(...(yield* BedrockMedia.lower(part, documentNames)))
continue
}
}
@@ -388,7 +403,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
if (part.type === "tool-call") {
content.push(lowerToolCall(part))
content.push(lowerToolCall(part, normalizeID))
continue
}
}
@@ -400,7 +415,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
content.push(yield* lowerToolResult(part))
content.push(yield* lowerToolResult(part, documentNames, normalizeID))
const cachePoint = BedrockCache.block(breakpoints, part.cache)
if (cachePoint) content.push(cachePoint)
}
+5 -33
View File
@@ -21,11 +21,11 @@ import {
import { classifyProviderFailure } from "../provider-error.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { MistralToolID } from "./utils/mistral-tool-id.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "mistral-chat"
const DONE = "[DONE]" as const
const TOOL_ID = /^[A-Za-z0-9]{9}$/
export const DEFAULT_BASE_URL = "https://api.mistral.ai/v1"
export const PATH = "/chat/completions"
@@ -223,34 +223,6 @@ const MistralEvent = Schema.StructWithRest(
type MistralEvent = Schema.Schema.Type<typeof MistralEvent>
const MistralStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(MistralEvent)])
const hashID = (value: string) => {
const hash = (seed: number) => {
let result = seed
for (const char of value) result = Math.imul(result ^ char.charCodeAt(0), 16777619)
return (result >>> 0).toString(36)
}
return `${hash(2166136261).padStart(7, "0")}${hash(2246822519).padStart(7, "0")}`.slice(-9)
}
const toolIDNormalizer = (request: LLMRequest) => {
const ids = request.messages.flatMap((message) =>
message.content.flatMap((part) => (part.type === "tool-call" || part.type === "tool-result" ? [part.id] : [])),
)
const used = new Set(ids.filter((id) => TOOL_ID.test(id)))
const normalized = new Map<string, string>()
return (id: string) => {
if (TOOL_ID.test(id)) return id
const previous = normalized.get(id)
if (previous) return previous
let attempt = 0
let candidate = hashID(id)
while (used.has(candidate)) candidate = hashID(`${id}:${++attempt}`)
used.add(candidate)
normalized.set(id, candidate)
return candidate
}
}
const lowerMedia = Effect.fn("MistralChat.lowerMedia")(function* (part: MediaPart) {
const media = ProviderShared.normalizeMedia(part)
const url = typeof part.data === "string" && /^(?:https?:|data:)/.test(part.data) ? part.data : media.dataUrl
@@ -359,7 +331,7 @@ const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
})
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
const normalizeID = toolIDNormalizer(request)
const normalizeID = MistralToolID.normalizer(request)
const messages: MistralMessage[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
for (const message of request.messages) {
@@ -582,13 +554,13 @@ const appendContent = (
}
const normalizeStreamToolID = (state: ParserState, source: string) => {
if (TOOL_ID.test(source))
if (MistralToolID.valid.test(source))
return { id: source, state: { ...state, usedToolIDs: new Set([...state.usedToolIDs, source]) } }
const previous = state.toolIDs.get(source)
if (previous) return { id: previous, state }
let attempt = 0
let id = hashID(source)
while (state.usedToolIDs.has(id)) id = hashID(`${source}:${++attempt}`)
let id = MistralToolID.hash(source)
while (state.usedToolIDs.has(id)) id = MistralToolID.hash(`${source}:${++attempt}`)
return {
id,
state: {
+1 -1
View File
@@ -766,7 +766,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
supportsStrictMode,
),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
tool_choice: hasActiveTools && request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}),
...(zaiToolStream && hasActiveTools ? { tool_stream: true } : {}),
@@ -199,8 +199,10 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
? undefined
: yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, toolSchemaCompatibility)),
tool_choice:
OpenResponses.allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
request.tools.length === 0
? undefined
: (OpenResponses.allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined)),
})
})
@@ -57,6 +57,25 @@ const documentBlock = (name: string, format: DocumentFormat, bytes: string): Doc
},
})
function documentName(filename: string | undefined, names: Set<string>) {
const base =
(filename ?? "")
.replace(/\.[^.]*$/, "")
.replace(/[^a-zA-Z0-9 ()[\]-]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 200)
.trim() || "document"
let name = base
// Converse requires labels to be unique across the entire request, including tool results.
for (let index = 2; names.has(name); index++) {
const suffix = ` ${index}`
name = `${base.slice(0, 200 - suffix.length).trimEnd()}${suffix}`
}
names.add(name)
return name
}
const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: MediaPart) {
const media = ProviderShared.normalizeMedia(part)
const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(
@@ -72,19 +91,26 @@ const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: Media
// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
// get an image-specific error so the caller knows it's a format-support issue,
// not a kind-detection issue.
export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart) {
export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart, documentNames: Set<string>) {
const mime = part.mediaType.toLowerCase()
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
if (imageFormat) {
return { image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock
return [{ image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock]
}
if (mime.startsWith("image/"))
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS]
if (documentFormat) {
if (!part.filename)
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
return documentBlock(part.filename, documentFormat, yield* mediaBase64(part))
const name = documentName(part.filename, documentNames)
const block = documentBlock(name, documentFormat, yield* mediaBase64(part))
return part.filename !== undefined && part.filename !== name
? [
{
text: `Attached file ${ProviderShared.encodeJson(part.filename)} has document label ${ProviderShared.encodeJson(name)}.`,
},
block,
]
: [block]
}
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
})
@@ -0,0 +1,34 @@
import type { LLMRequest } from "../../schema/index.js"
export const valid = /^[A-Za-z0-9]{9}$/
export const hash = (value: string) => {
const hash = (seed: number) => {
let result = seed
for (const char of value) result = Math.imul(result ^ char.charCodeAt(0), 16777619)
return (result >>> 0).toString(36)
}
return `${hash(2166136261).padStart(7, "0")}${hash(2246822519).padStart(7, "0")}`.slice(-9)
}
export const normalizer = (request: LLMRequest) => {
const ids = request.messages.flatMap((message) =>
message.content.flatMap((part) => (part.type === "tool-call" || part.type === "tool-result" ? [part.id] : [])),
)
// Reserve valid IDs before projecting any history, including IDs encountered later.
const used = new Set(ids.filter((id) => valid.test(id)))
const normalized = new Map<string, string>()
return (id: string) => {
if (valid.test(id)) return id
const previous = normalized.get(id)
if (previous) return previous
let attempt = 0
let candidate = hash(id)
while (used.has(candidate)) candidate = hash(`${id}:${++attempt}`)
used.add(candidate)
normalized.set(id, candidate)
return candidate
}
}
export * as MistralToolID from "./mistral-tool-id.js"
+7 -1
View File
@@ -23,7 +23,13 @@ export interface DispatchResult extends ToolSettlement {
export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<DispatchResult> => {
const name = call.namespace === undefined ? call.name : `${call.namespace}.${call.name}`
const tool = tools[name]
if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${name}` }))
if (!tool)
return Effect.succeed(
result(call, {
type: "error",
value: `No tool named "${name}" is currently available. Please use a tool from the available tool list.`,
}),
)
if (!tool.execute)
return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${name}` }))
+11
View File
@@ -325,6 +325,7 @@ describe("provider package entrypoints", () => {
test("requires an Anthropic-compatible base URL at runtime", async () => {
const AnthropicCompatible = await import("@opencode/ai/providers/anthropic-compatible")
expect(() =>
// oxlint-disable-next-line no-restricted-globals -- This test intentionally bypasses static required-option checks.
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible providers require a baseURL"))
})
@@ -333,6 +334,7 @@ describe("provider package entrypoints", () => {
const Anthropic = await import("@opencode/ai/providers/anthropic")
const AnthropicCompatible = await import("@opencode/ai/providers/anthropic-compatible")
expect(() =>
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes a statically invalid option combination.
Reflect.apply(AnthropicCompatible.model, undefined, [
"compatible-model",
{
@@ -343,6 +345,7 @@ describe("provider package entrypoints", () => {
]),
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible apiKey cannot be combined with authToken"))
expect(() =>
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes a statically invalid option combination.
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
).toThrow(configuration("anthropic", "Anthropic apiKey cannot be combined with authToken"))
})
@@ -490,11 +493,13 @@ describe("provider package entrypoints", () => {
const GoogleVertexResponses = await import("@opencode/ai/providers/google-vertex/responses")
const Providers = await import("@opencode/ai/providers")
expect(() =>
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes a statically invalid option combination.
Reflect.apply(GoogleVertex.model, undefined, [
"gemini-3.5-flash",
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex apiKey cannot be combined with accessToken or auth"))
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes a statically invalid option combination.
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
{ accessToken: "token", auth: {}, project: "vertex-project" },
])
@@ -502,34 +507,40 @@ describe("provider package entrypoints", () => {
configuration("google-vertex", "Google Vertex accessToken cannot be combined with auth"),
)
expect(() =>
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes an unsupported authentication option.
Reflect.apply(GoogleVertexMessages.model, undefined, [
"claude-sonnet-4-6",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
expect(() =>
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes an unsupported authentication option.
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
expect(() =>
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes an unsupported authentication option.
Reflect.apply(GoogleVertexChat.model, undefined, [
"deepseek-ai/deepseek-v3.2-maas",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
expect(() =>
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes an unsupported authentication option.
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
expect(() =>
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes an unsupported authentication option.
Reflect.apply(GoogleVertexResponses.model, undefined, [
"xai/grok-4.20-reasoning",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
expect(() =>
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes an unsupported authentication option.
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
@@ -1,7 +1,7 @@
import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { describe, expect } from "bun:test"
import { Effect, Encoding, Ref, Stream } from "effect"
import { Effect, Encoding, Ref, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import {
CacheHint,
@@ -11,9 +11,11 @@ import {
LLMEvent,
LLMRequest,
Message,
Tool,
ToolCallPart,
ToolChoice,
ToolDefinition,
ToolRuntime,
} from "../../src/index.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
@@ -279,6 +281,52 @@ describe("Bedrock Converse route", () => {
})
}),
)
;["", " \t\r\n"].forEach((description) => {
it.effect(`omits blank tool description ${JSON.stringify(description)}`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(baseRequest, {
tools: [
ToolDefinition.make({
name: "lookup",
description,
inputSchema: { type: "object", properties: { query: { type: "string" } } },
}),
],
}),
)
expect(prepared.body.toolConfig.tools).toEqual([
{
toolSpec: {
name: "lookup",
inputSchema: { json: { type: "object", properties: { query: { type: "string" } } } },
},
},
])
expect(prepared.body.toolConfig.tools[0].toolSpec).not.toHaveProperty("description")
}),
)
})
it.effect("preserves meaningful tool descriptions including surrounding whitespace", () =>
Effect.gen(function* () {
const description = " \tLookup data.\n"
const prepared = yield* compileRequest(
LLMRequest.update(baseRequest, {
tools: [
ToolDefinition.make({
name: "lookup",
description,
inputSchema: { type: "object" },
}),
],
}),
)
expect(prepared.body.toolConfig.tools[0].toolSpec.description).toBe(description)
}),
)
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
Effect.gen(function* () {
@@ -348,6 +396,45 @@ describe("Bedrock Converse route", () => {
})
}),
)
;[
{ name: "browser.tabs.open", expected: "browser_tabs_open" },
{ name: "$lookup", expected: "_lookup" },
{ name: "", expected: "_" },
{ name: " ", expected: "___" },
{ name: "a".repeat(65), expected: "a".repeat(64) },
{ name: "lookup_123-ABC", expected: "lookup_123-ABC" },
{ name: "a".repeat(64), expected: "a".repeat(64) },
].forEach((item) => {
it.effect(`replays historical tool name ${JSON.stringify(item.name)} within Bedrock constraints`, () =>
Effect.gen(function* () {
const call = ToolCallPart.make({ id: "call_unknown", name: item.name, input: { query: "weather" } })
const error = `No tool named "${item.name}" is currently available. Please use a tool from the available tool list.`
const request = LLM.request({
model,
cache: "none",
tools: [ToolDefinition.make({ name: "execute", description: "Run code", inputSchema: { type: "object" } })],
messages: [
Message.user("Check the weather"),
Message.assistant([call]),
Message.tool({ id: call.id, name: call.name, result: error, resultType: "error" }),
Message.user("Say OK"),
],
})
const prepared = yield* compileRequest(request)
expect(prepared.body.messages[1].content).toEqual([
{ toolUse: { toolUseId: call.id, name: item.expected, input: call.input } },
])
expect(prepared.body.messages[2].content).toEqual([
{ toolResult: { toolUseId: call.id, content: [{ text: error }], status: "error" } },
{ text: "Say OK" },
])
expect(prepared.body.toolConfig.tools.map((tool) => tool.toolSpec.name)).toEqual(["execute"])
expect(request.messages[1].content[0]).toEqual(call)
expect(call.name).toBe(item.name)
}),
)
})
it.effect("removes empty keys recursively from outbound tool inputs without mutating history", () =>
Effect.gen(function* () {
@@ -751,6 +838,57 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects a provider-emitted dotted name before normalizing its replay", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
[
"contentBlockStart",
{ contentBlockIndex: 0, start: { toolUse: { toolUseId: "call_unknown", name: "browser.tabs.open" } } },
],
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: "{}" } } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "tool_use" }],
),
),
),
)
const call = response.toolCalls[0]
if (!call) throw new Error("Expected a tool call")
expect(call.name).toBe("browser.tabs.open")
const dispatched = yield* ToolRuntime.dispatch(
{
browser_tabs_open: Tool.make({
description: "Open a tab",
parameters: Schema.Struct({}),
success: Schema.String,
execute: () => Effect.die("A normalized replay name must not select an executor"),
}),
},
call,
)
expect(dispatched.result).toEqual({
type: "error",
value:
'No tool named "browser.tabs.open" is currently available. Please use a tool from the available tool list.',
})
const prepared = yield* compileRequest(
LLM.request({
model,
cache: "none",
messages: [response.message, Message.tool({ id: call.id, name: call.name, result: dispatched.result })],
}),
)
expect(prepared.body.messages[0].content).toEqual([
{ toolUse: { toolUseId: call.id, name: "browser_tabs_open", input: {} } },
])
expect(response.toolCalls[0]?.name).toBe("browser.tabs.open")
}),
)
it.effect("ignores tool deltas without an open tool block", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -1627,29 +1765,141 @@ describe("Bedrock Converse route", () => {
role: "user",
content: [
{ text: "Summarize these documents." },
{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
{ document: { format: "csv", name: "data.csv", source: { bytes: "Q1NWREFUQQ==" } } },
{ text: 'Attached file "report.pdf" has document label "report".' },
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
{ text: 'Attached file "data.csv" has document label "data".' },
{ document: { format: "csv", name: "data", source: { bytes: "Q1NWREFUQQ==" } } },
],
},
],
})
}),
)
;[
{
label: "filename punctuation",
filename: "report_v1.2?.pdf",
expected: "report v1 2",
duplicate: "report v1 2 2",
},
{
label: "repeated whitespace",
filename: " Quarterly\t \n report.txt",
expected: "Quarterly report",
duplicate: "Quarterly report 2",
},
{
label: "allowed characters",
filename: "Report - Final (v2) [2026]",
expected: "Report - Final (v2) [2026]",
duplicate: "Report - Final (v2) [2026] 2",
},
{ label: "accented filename", filename: "résumé.pdf", expected: "r sum", duplicate: "r sum 2" },
{ label: "non-Latin filename", filename: "報告書.pdf", expected: "document", duplicate: "document 2" },
{ label: "missing filename", filename: undefined, expected: "document", duplicate: "document 2" },
{ label: "empty filename", filename: "", expected: "document", duplicate: "document 2" },
{ label: "blank filename", filename: " \t\n", expected: "document", duplicate: "document 2" },
{ label: "extension-only filename", filename: ".pdf", expected: "document", duplicate: "document 2" },
{ label: "symbols-only filename", filename: "@@@.pdf", expected: "document", duplicate: "document 2" },
{
label: "overlong filename",
filename: `${"a".repeat(201)}.txt`,
expected: "a".repeat(200),
duplicate: `${"a".repeat(198)} 2`,
},
{
label: "maximum-length label",
filename: "a".repeat(200),
expected: "a".repeat(200),
duplicate: `${"a".repeat(198)} 2`,
},
{
label: "whitespace at truncation",
filename: `${"a".repeat(199)} b.txt`,
expected: "a".repeat(199),
duplicate: `${"a".repeat(198)} 2`,
},
].forEach((item) => {
it.effect(`normalizes ${item.label} in user and tool-result documents`, () =>
Effect.gen(function* () {
const request = LLM.request({
model,
cache: "none",
messages: [
Message.user([
{ type: "text", text: "Read this document" },
{ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: item.filename },
]),
Message.assistant([ToolCallPart.make({ id: "call_read", name: "read", input: {} })]),
Message.tool({
id: "call_read",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Read successfully" },
{
type: "file",
uri: "data:application/pdf;base64,UERGREFUQQ==",
mime: "application/pdf",
name: item.filename,
},
],
},
}),
],
})
const original = JSON.stringify(request.messages)
const first = yield* compileRequest(request)
const second = yield* compileRequest(request)
const expected = { format: "pdf", name: item.expected, source: { bytes: "UERGREFUQQ==" } }
it.effect("requires names for document media", () =>
expect(first.body.messages[0].content.find((part) => "document" in part)?.document).toEqual(expected)
expect(
first.body.messages[2].content[0].toolResult.content.find((part) => "document" in part)?.document,
).toEqual({
...expected,
name: item.duplicate,
})
expect(second.body).toEqual(first.body)
expect(JSON.stringify(request.messages)).toBe(original)
}),
)
})
it.effect("keeps colliding document labels distinct within a request", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
cache: "none",
messages: [
Message.user([
{ type: "text", text: "Read these documents" },
...["report_v1.txt", "report#v1.txt", "report v1 2.txt", "report v1.txt"].map((filename) => ({
type: "media" as const,
mediaType: "text/plain",
data: "SGVsbG8=",
filename,
})),
]),
],
}),
).pipe(Effect.flip)
expect(error.message).toContain("document media requires a filename")
)
expect(prepared.body.messages[0].content.slice(1)).toEqual([
{ text: 'Attached file "report_v1.txt" has document label "report v1".' },
{ document: { format: "txt", name: "report v1", source: { bytes: "SGVsbG8=" } } },
{ text: 'Attached file "report#v1.txt" has document label "report v1 2".' },
{ document: { format: "txt", name: "report v1 2", source: { bytes: "SGVsbG8=" } } },
{ text: 'Attached file "report v1 2.txt" has document label "report v1 2 2".' },
{ document: { format: "txt", name: "report v1 2 2", source: { bytes: "SGVsbG8=" } } },
{ text: 'Attached file "report v1.txt" has document label "report v1 3".' },
{ document: { format: "txt", name: "report v1 3", source: { bytes: "SGVsbG8=" } } },
])
}),
)
it.effect("passes named document-only messages through for provider validation", () =>
it.effect("annotates renamed document-only messages with their original filename", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -1669,12 +1919,44 @@ describe("Bedrock Converse route", () => {
expect(prepared.body.messages).toEqual([
{
role: "user",
content: [{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } }],
content: [
{ text: 'Attached file "report.pdf" has document label "report".' },
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
],
},
])
}),
)
it.effect("quotes original filenames in annotations and omits redundant mappings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
cache: "none",
messages: [
Message.user([
{ type: "text", text: "Read these documents" },
...["report", undefined, 'report "final"\n.pdf'].map((filename) => ({
type: "media" as const,
mediaType: "text/plain",
data: "SGVsbG8=",
filename,
})),
]),
],
}),
)
expect(prepared.body.messages[0].content).toEqual([
{ text: "Read these documents" },
{ document: { format: "txt", name: "report", source: { bytes: "SGVsbG8=" } } },
{ document: { format: "txt", name: "document", source: { bytes: "SGVsbG8=" } } },
{ text: 'Attached file "report \\"final\\"\\n.pdf" has document label "report final".' },
{ document: { format: "txt", name: "report final", source: { bytes: "SGVsbG8=" } } },
])
}),
)
it.effect("lowers document media in tool results", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1694,7 +1976,7 @@ describe("Bedrock Converse route", () => {
type: "file",
uri: "data:application/pdf;base64,UERGREFUQQ==",
mime: "application/pdf",
name: "report",
name: "report.pdf",
},
],
},
@@ -1717,6 +1999,7 @@ describe("Bedrock Converse route", () => {
status: "success",
content: [
{ text: "Read successfully" },
{ text: 'Attached file "report.pdf" has document label "report".' },
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
],
},
@@ -0,0 +1,86 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, Message, ToolCallPart } from "../../src/index.js"
import { AmazonBedrock } from "../../src/providers.js"
import { BedrockConverse } from "../../src/protocols/bedrock-converse.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
const bedrock = AmazonBedrock.configure({ baseURL: "https://bedrock.test", apiKey: "test-key" })
const history = (ids: string[]) => [
Message.user("Read every label"),
Message.assistant(ids.map((id, index) => ToolCallPart.make({ id, name: "lookup", input: { label: index } }))),
...ids.map((id, index) => Message.tool({ id, name: "lookup", result: `value-${index}` })).toReversed(),
]
for (const model of [
"mistral.mistral-large-2407-v1:0",
"us.mistral.pixtral-large-2502-v1:0",
"arn:aws:bedrock:us-east-1::foundation-model/mistral.pixtral-large-2502-v1:0",
]) {
it.effect(`preserves distinct call/result pairs and history for ${model}`, () =>
Effect.gen(function* () {
const request = LLM.request({ model: bedrock.model(model), messages: history(["a"]), cache: "none" })
const first = Schema.decodeUnknownSync(BedrockConverse.protocol.body.schema)(
(yield* compileRequest(request)).body,
)
const reserved = first.messages.flatMap((message) =>
message.content.flatMap((part) => ("toolUse" in part ? [part.toolUse.toolUseId] : [])),
)[0]
if (!reserved) throw new Error("Expected projected tool call")
const ids = [
"a",
"b",
"tooluse_GQn7COr2AN8bw2oVKYyYzZ",
"tooluse_abcdefghi111111111",
"tooluse_abcdefghi222222222",
"call_other-provider-id",
"Ab12Cd34E",
reserved,
]
const messages = history(ids)
const before = structuredClone(messages)
const input = LLM.request({ model: bedrock.model(model), messages, cache: "none" })
const prepared = yield* compileRequest(input)
const body = Schema.decodeUnknownSync(BedrockConverse.protocol.body.schema)(prepared.body)
const calls = body.messages.flatMap((message) =>
message.content.flatMap((part) => ("toolUse" in part ? [part.toolUse.toolUseId] : [])),
)
const results = body.messages.flatMap((message) =>
message.content.flatMap((part) => ("toolResult" in part ? [part.toolResult.toolUseId] : [])),
)
expect(calls).toHaveLength(ids.length)
expect(new Set(calls).size).toBe(ids.length)
calls.forEach((id) => expect(id).toMatch(/^[A-Za-z0-9]{9}$/))
expect(results).toEqual(calls.toReversed())
expect(calls[0]).not.toBe(reserved)
expect(calls.slice(-2)).toEqual(ids.slice(-2))
expect((yield* compileRequest(input)).body).toEqual(prepared.body)
expect(structuredClone(messages)).toEqual(before)
}),
)
}
it.effect("leaves non-Mistral Bedrock IDs unchanged", () =>
Effect.gen(function* () {
const ids = ["a", "tooluse_abcdefghi111111111", "tooluse_abcdefghi222222222", "Ab12Cd34E"]
const prepared = yield* compileRequest(
LLM.request({
model: bedrock.model("global.anthropic.claude-haiku-4-5-20251001-v1:0"),
messages: history(ids),
cache: "none",
}),
)
const body = Schema.decodeUnknownSync(BedrockConverse.protocol.body.schema)(prepared.body)
expect(
body.messages.flatMap((message) =>
message.content.flatMap((part) => ("toolUse" in part ? [part.toolUse.toolUseId] : [])),
),
).toEqual(ids)
expect(
body.messages.flatMap((message) =>
message.content.flatMap((part) => ("toolResult" in part ? [part.toolResult.toolUseId] : [])),
),
).toEqual(ids.toReversed())
}),
)
@@ -0,0 +1,80 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, Message, ToolCallPart } from "../../src/index.js"
import { OpenAIChat } from "../../src/protocols/openai-chat.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { Auth } from "../../src/route/auth.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
const tool = { name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: {} } }
for (const route of [OpenAIChat.route, OpenAIResponses.route]) {
const model = route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4.1-mini" })
for (const choice of [
{ type: "auto" },
{ type: "none" },
{ type: "required" },
{ type: "tool", name: "lookup" },
] as const) {
it.effect(`${route.id} omits ${choice.type} tool choice without active tools`, () =>
Effect.gen(function* () {
for (const messages of [
[Message.user("Say OK.")],
[
Message.user("Look up the value."),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "OK", resultType: "text" }),
],
]) {
const prepared = yield* compileRequest(
LLM.request({ model, messages, tools: [], toolChoice: choice, cache: "none" }),
)
expect(prepared.body.tool_choice).toBeUndefined()
}
}),
)
it.effect(`${route.id} preserves ${choice.type} tool choice with active tools`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model, prompt: "Look up the value.", tools: [tool], toolChoice: choice }),
)
expect(prepared.body.tool_choice).toEqual(
choice.type !== "tool"
? choice.type
: route.id === "openai-chat"
? { type: "function", function: { name: "lookup" } }
: { type: "function", name: "lookup" },
)
}),
)
}
}
it.effect("OpenAI Responses omits allowed tool choice without tool definitions", () =>
Effect.gen(function* () {
const model = OpenAIResponses.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4.1-mini" })
for (const tools of [[], [tool]]) {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Look up the value.",
tools,
toolChoice: "required",
providerOptions: { allowedTools: { mode: "required", toolNames: ["lookup"] } },
}),
)
expect(prepared.body.tool_choice).toEqual(
tools.length === 0
? undefined
: { type: "allowed_tools", mode: "required", tools: [{ type: "function", name: "lookup" }] },
)
}
}),
)
+20 -10
View File
@@ -120,16 +120,16 @@ describe("LLMClient tools", () => {
const second = bodies[1]
if (!second || typeof second !== "object") throw new Error("Expected second request body")
const messages = Reflect.get(second, "messages")
const tools = Reflect.get(second, "tools")
const messages = "messages" in second ? second.messages : undefined
const tools = "tools" in second ? second.tools : undefined
expect(Reflect.get(second, "max_completion_tokens")).toBe(50)
expect(Reflect.get(second, "tool_choice")).toBe("auto")
expect("max_completion_tokens" in second ? second.max_completion_tokens : undefined).toBe(50)
expect("tool_choice" in second ? second.tool_choice : undefined).toBe("auto")
expect(tools).toHaveLength(1)
expect(
Array.isArray(messages)
? messages.map((message) =>
message && typeof message === "object" ? Reflect.get(message, "role") : undefined,
message && typeof message === "object" && "role" in message ? message.role : undefined,
)
: undefined,
).toEqual(["user", "assistant", "tool"])
@@ -237,13 +237,16 @@ describe("LLMClient tools", () => {
LLMEvent.toolError({
id: "call_2",
name: "missing",
message: "Unknown tool: missing",
message: 'No tool named "missing" is currently available. Please use a tool from the available tool list.',
providerMetadata,
}),
LLMEvent.toolResult({
id: "call_2",
name: "missing",
result: { type: "error", value: "Unknown tool: missing" },
result: {
type: "error",
value: 'No tool named "missing" is currently available. Please use a tool from the available tool list.',
},
providerMetadata,
}),
])
@@ -395,7 +398,9 @@ describe("LLMClient tools", () => {
required: ["temperature", "condition"],
additionalProperties: false,
})
expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
const properties =
typed?.outputSchema && "properties" in typed.outputSchema ? typed.outputSchema.properties : undefined
expect(properties && "temperature" in properties ? properties.temperature : undefined).toBeDefined()
expect(dynamic?.outputSchema).toEqual(schema)
})
@@ -712,12 +717,17 @@ describe("LLMClient tools", () => {
const toolError = events.find(LLMEvent.is.toolError)
expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" })
expect(toolError?.message).toContain("Unknown tool")
expect(toolError?.message).toBe(
'No tool named "missing_tool" is currently available. Please use a tool from the available tool list.',
)
expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
type: "tool-result",
id: "call_1",
name: "missing_tool",
result: { type: "error", value: "Unknown tool: missing_tool" },
result: {
type: "error",
value: 'No tool named "missing_tool" is currently available. Please use a tool from the available tool list.',
},
})
}),
)
@@ -63,20 +63,20 @@ for (const size of ["typical", "large"]) {
const source = page.locator(`[data-timeline-part-id="${sourcePart}"] [data-component="markdown"]`)
await expect(source).toHaveAttribute("data-markdown-ready", "")
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`).click()
await page.waitForFunction(() => Reflect.get(window, "markdownGate").held)
await page.waitForFunction(() => window.markdownGate.held)
await expect(page.locator(`[data-timeline-part-id="${targetPart}"]`)).toBeAttached()
const cdp = await page.context().newCDPSession(page)
await cdp.send("Performance.enable")
const before = await cdp.send("Performance.getMetrics")
await page.evaluate(() => Reflect.get(window, "markdownGate").arm())
await page.evaluate(() => window.markdownGate.arm())
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(source).toHaveAttribute("data-markdown-ready", "")
await expect(source.getByRole("heading", { name: "Current destination" })).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${targetPart}"]`)).toHaveCount(0)
await page.waitForFunction(() => Reflect.get(window, "markdownGate").settled > 0)
await page.waitForFunction(() => window.markdownGate.settled > 0)
const after = await cdp.send("Performance.getMetrics")
const stats = await page.evaluate(() => {
const value = Reflect.get(window, "markdownGate")
const value = window.markdownGate
return {
admitted: value.admitted,
responses: value.responses,
@@ -4,6 +4,23 @@ import type {
MarkdownWorkerResponse,
} from "../../../../session-ui/src/components/markdown-worker-protocol"
declare global {
interface Window {
markdownGate: {
admitted: number
responses: number
held: boolean
started: number
ready: number
released: number
settled: number
sanitizeCalls: number
sanitizeChars: number
arm: () => void
}
}
}
export async function installMarkdownGate(
page: Page,
input: { answer: string; sourcePart: string; targetPart: string; href: string },
@@ -51,7 +51,7 @@ Terminal.prototype.open = function (element) {
context.drawImage = function (...args: unknown[]) {
probe.draws++
if (hidden) probe.hiddenDraws++
Reflect.apply(draw, this, args)
draw.apply(this, args)
}
}
}
@@ -74,7 +74,7 @@ for (const scenario of ["visible-output", "hidden-output", "full-scrollback-tear
window.terminalProbe.draws++
if (!this.canvas.checkVisibility()) window.terminalProbe.hiddenDraws++
}
Reflect.apply(fill, this, args)
fill.apply(this, args)
}
})
}
@@ -136,7 +136,7 @@ export async function installTimelineStreamProbe(
}
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
state.scroll.lastCallFrame = state.scroll.frame
Reflect.apply(scrollTo, this, typeof first === "number" ? [first, second] : [first])
scrollTo.apply(this, typeof first === "number" ? [first, second] : [first])
}
Element.prototype.scrollTo = measuredScrollTo
Object.defineProperty(Element.prototype, "scrollTop", {
@@ -0,0 +1,37 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test("loads home and the directory picker without newer browser APIs", async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
fileList: () => [],
})
await page.addInitScript((directory) => {
// Safari 16.6 has neither API. Remove them before the web entry runs.
delete (Map as Partial<typeof Map>).groupBy
delete (Promise as Partial<typeof Promise>).withResolvers
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle })
await expect(row).toBeVisible()
await page.getByRole("button", { name: "Add project", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toBeHidden()
await expect(row).toBeVisible()
})
@@ -1,195 +0,0 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/slash-skills"
const projectID = "proj_slash_skills"
const sessionID = "ses_slash_skills"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
async function setup(page: Page, queued = false) {
const prompts: Record<string, unknown>[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "slash-skills",
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,
projectID,
directory,
title: "Slash skills",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
sessionStatus: queued ? { [sessionID]: { type: "running" } } : {},
inbox: queued
? [
{
id: "inb_slash_skill",
sessionID,
timeCreated: 1700000000000,
type: "user",
payload: { text: "Explain caching" },
delivery: "queue",
},
]
: [],
findFiles: ({ query }) =>
query.includes("cache")
? [
{
name: "cache.ts",
path: "src/cache.ts",
absolute: `${directory}/src/cache.ts`,
type: "file",
ignored: false,
},
]
: [],
onPrompt: ({ body }) => prompts.push(body),
})
await page.route("**/api/skill?*", (route) =>
route.fulfill({
json: {
location: { directory, project: { id: projectID, directory, canonical: directory } },
data: [
{ id: "show-me", slash: true, autoinvoke: false },
{ id: "hidden", slash: false },
{ id: "implicit" },
{ id: "review", slash: true },
{ id: "model", slash: true },
].map((skill) => ({
name: skill.id === "show-me" ? "Show Me" : skill.id,
description: "Explain the current topic visually",
location: `/skills/${skill.id}/SKILL.md`,
content: "Explain visually",
...skill,
})),
},
headers: { "access-control-allow-origin": "*" },
}),
)
await page.route("**/api/command?*", (route) =>
route.fulfill({
json: {
location: { directory, project: { id: projectID, directory, canonical: directory } },
data: [{ name: "review", description: "Review changes" }],
},
headers: { "access-control-allow-origin": "*" },
}),
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="composer"]')
const editor = composer.locator('[data-component="composer-editor"]')
await expect(editor).toBeEditable()
return { prompts, composer, editor }
}
for (const selection of ["keyboard", "pointer"]) {
test(`selects and submits a manual-only slash skill with ${selection}`, async ({ page }) => {
const { prompts, editor } = await setup(page)
await editor.fill("/show")
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
await expect(skill).toContainText("/show-me")
await page.screenshot({ path: test.info().outputPath("slash-menu.png") })
if (selection === "keyboard") await editor.press("Enter")
if (selection === "pointer") await skill.click()
await expect(editor).toHaveText("/show-me ")
await expect(editor).toBeFocused()
await editor.pressSequentially("explain caching")
await editor.press("Enter")
await expect.poll(() => prompts.length).toBe(1)
expect(prompts[0]).toMatchObject({
text: "/show-me explain caching",
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
})
await expect(editor).toBeEmpty()
})
}
test("respects slash flags and command precedence without hiding context skills", async ({ page }) => {
const { editor } = await setup(page)
await editor.fill("/")
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toBeVisible()
await expect(page.locator('[data-suggestion-id="custom.review"]')).toBeVisible()
await expect(page.locator('[data-suggestion-id="model.choose"]')).toBeVisible()
for (const id of ["hidden", "implicit", "review", "model"]) {
await expect(page.locator(`[data-suggestion-id="skill:${id}"]`)).toHaveCount(0)
}
await editor.press("Escape")
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toHaveCount(0)
await expect(editor).toBeFocused()
await editor.fill("@hidden")
const hidden = page.locator('[data-suggestion-id="skill:hidden"]')
await expect(hidden).toContainText("@hidden")
await hidden.click()
await expect(editor).toHaveText("@hidden ")
await expect(editor).toBeFocused()
})
test("preserves structured attachments when adding a slash skill from the command menu", async ({ page }) => {
const { prompts, composer, editor } = await setup(page)
await editor.fill("explain @cache")
const file = page.locator('[data-suggestion-id="file:src/cache.ts"]')
await expect(file).toBeVisible()
await file.click()
await expect(editor).toHaveText("explain @src/cache.ts ")
await composer.getByRole("button", { name: "Add images and files" }).click()
await page.getByRole("menuitem", { name: "Commands" }).click()
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
await expect(skill).toBeVisible()
await skill.click()
await expect(editor).toHaveText("/show-me explain @src/cache.ts ")
await expect(editor).toBeFocused()
await editor.press("Enter")
await expect.poll(() => prompts.length).toBe(1)
expect(prompts[0]).toMatchObject({
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
files: [{ name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } }],
})
})
for (const select of [true, false]) {
test(`keeps slash skills in queued edits (${select ? "selected" : "typed"})`, async ({ page }) => {
const { prompts, editor } = await setup(page, true)
const row = page.locator('[data-component="session-queue-row"]')
await row.getByText("Explain caching", { exact: true }).click()
await expect(editor).toHaveText("Explain caching")
if (select) {
await editor.fill("/show")
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
await expect(skill).toBeVisible()
await skill.click()
await expect(editor).toHaveText("/show-me ")
await editor.pressSequentially("explain caching")
}
if (!select) await editor.fill("/show-me explain caching")
await editor.press("Enter")
await expect.poll(() => prompts.length).toBe(1)
expect(prompts[0]).toMatchObject({
delivery: "queue",
text: "/show-me explain caching",
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
})
})
}
@@ -62,7 +62,7 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/status") return json(route, { version: "test", pid: 1, urls: [url.origin] })
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) })
@@ -76,7 +76,7 @@ async function mockServers(page: Page, requests: string[]) {
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
if (url.pathname === "/api/project") {
const project = {
id: current.projectID,
canonical: current.directory,
@@ -84,7 +84,7 @@ async function mockServers(page: Page, requests: string[]) {
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
return json(route, [project])
}
if (url.pathname === "/api/location")
return json(route, {
@@ -59,8 +59,9 @@ for (const shared of [true, false]) {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await page.keyboard.press("ControlOrMeta+;")
const dialog = page.getByRole("dialog", { name: "MCPs", exact: true })
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const dialog = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(dialog.getByText("figma-desktop", { exact: true })).toBeVisible()
const toggle = dialog.getByRole("switch")
await expect(toggle).not.toBeChecked()
@@ -92,7 +93,7 @@ for (const surface of ["popover", "dialog"] as const) {
const state = { fail: true, status: surface === "popover" ? "failed" : "disabled" }
const requests: { path: string; directory: string }[] = []
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { showStatus: true } }))
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "mcp.toggle": "ctrl+;" } }))
})
await mockOpenCodeServer(page, {
directory,
@@ -113,6 +114,10 @@ for (const surface of ["popover", "dialog"] as const) {
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
requests.push({ path: url.pathname, directory: target })
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
state.status = "disabled"
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/figma-desktop/connect") {
state.status = state.fail ? "failed" : "connected"
// Connection failures are reported by the refreshed status, not the HTTP response.
@@ -137,12 +142,19 @@ for (const surface of ["popover", "dialog"] as const) {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
const panel =
surface === "popover" ? page.getByRole("tabpanel") : page.getByRole("dialog", { name: "MCPs", exact: true })
if (surface === "popover") {
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
}
if (surface === "dialog") await page.keyboard.press("Control+;")
const panel = page.getByRole("dialog", { name: surface === "popover" ? "MCP" : "MCPs", exact: true })
const toggle = panel.getByRole("switch")
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
await expect(toggle).toBeEnabled()
if (surface === "popover") {
await expect(toggle).toBeChecked()
await panel.getByText("figma-desktop", { exact: true }).click()
}
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
requests.length = 0
@@ -152,7 +164,7 @@ for (const surface of ["popover", "dialog"] as const) {
.getByRole("listitem", { includeHidden: true })
.filter({ has: page.getByText("Request failed", { exact: true }) })
await expect(toast.getByText(`figma-desktop: ${error}`, { exact: true })).toBeVisible()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeChecked({ checked: surface === "popover" })
await expect(toggle).toBeEnabled()
expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
{ path: "/api/mcp/figma-desktop/connect", directory: workspace },
@@ -167,9 +179,18 @@ for (const surface of ["popover", "dialog"] as const) {
await toast.getByRole("button", { name: "Dismiss", exact: true }).click()
await expect(toast).toBeHidden()
state.fail = false
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
if (surface === "popover") {
await expect(page.getByRole("dialog", { name: "Session details", exact: true })).toBeHidden()
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
}
if (surface === "dialog") await page.keyboard.press("Control+;")
await expect(toggle).toBeEnabled()
if (surface === "popover") {
await panel.getByText("figma-desktop", { exact: true }).click()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
}
await panel.locator('[data-slot="switch-control"]').click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
@@ -104,18 +104,12 @@ for (const position of ["top", "bottom"] as const) {
await expect(composer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
const status = page.getByRole("dialog", { name: "Status", exact: true })
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await status.getByRole("button", { name: "Close", exact: true }).click()
await expect(status).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await details.getByRole("button", { name: "Close", exact: true }).click()
await expect(details).toBeHidden()
await expect(more).toBeFocused()
@@ -2,20 +2,21 @@ import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
test("summary drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.goto(stressSessionHref(fixture.targetID))
const more = page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await expect(drawer.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
// Corvu starts opening after paint; the transition flag is also absent
// before that callback. Wait for the open position before dismissing.
await expect
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,476 @@
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer, currentSession } from "../utils/mock-server"
import { openWithDirection } from "../utils/direction"
const directory = "/workspace/summary-project"
const workspace = "/workspace/existing-worktree"
const createdWorkspace = "/workspace/created-worktree"
const draftID = "draft_summary"
const secondDraftID = "draft_summary_other"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const draftPath = `/new-session?draftId=${draftID}`
for (const rtl of [false, true]) {
test(`new session summary shows project extensions and follows workspace selection in ${rtl ? "rtl" : "ltr"}`, async ({
page,
}, testInfo) => {
const mock = await openDraft(page, "main", { direction: rtl ? "rtl" : "ltr" })
await expect(page.locator("html")).toHaveAttribute("dir", rtl ? "rtl" : "ltr")
await expect(page.locator("html")).toHaveAttribute("lang", "en")
const trigger = page.getByRole("button", { name: "Session details", exact: true })
await expect
.poll(async () => {
const button = await trigger.boundingBox()
const view = await page.locator('[data-component="new-session"]').boundingBox()
if (!button || !view) return false
const gap = rtl ? button.x - view.x : view.x + view.width - button.x - button.width
return Math.abs(gap - 12) <= 1 && Math.abs(button.y + button.height / 2 - view.y - 24) <= 1
})
.toBe(true)
await trigger.click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(summary.getByRole("button", { name: "summary-project", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect
.poll(async () => {
const button = await trigger.boundingBox()
const project = await summary.locator('[data-section="project"]').boundingBox()
const server = await summary.locator('[data-section="server"]').boundingBox()
if (!button || !project || !server) return
return {
top: project.y - button.y - button.height,
cards: server.y - project.y - project.height,
}
})
.toEqual({ top: 12, cards: 8 })
await testInfo.attach(`new-session-summary-${rtl ? "rtl" : "ltr"}`, {
body: await page.screenshot(),
contentType: "image/png",
})
for (const [name, item] of [
["MCP", "summary-mcp"],
["Plugins", "project-plugin"],
["Skills", "summary-skill"],
["LSP", "typescript"],
]) {
await summary.getByRole("button", { name, exact: true }).click()
await expect(page.getByRole("dialog", { name, exact: true }).getByText(item, { exact: true })).toBeVisible()
}
await page.keyboard.press("Escape")
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
const worktreeMenu = page.getByRole("menu", { name: "Local repository", exact: true })
await expect
.poll(async () => {
const menu = await worktreeMenu.boundingBox()
const panel = await summary.locator('[data-component="session-summary-panel"]').boundingBox()
if (!menu || !panel) return false
return rtl ? menu.x >= panel.x + panel.width : menu.x + menu.width <= panel.x
})
.toBe(true)
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeHidden()
await worktreeMenu.getByRole("menuitem", { name: "Worktree", exact: true }).press(rtl ? "ArrowLeft" : "ArrowRight")
await expect(page.getByRole("menu", { name: "Worktree", exact: true })).toBeVisible()
await page.keyboard.press("Enter")
await expect(summary.getByRole("button", { name: "existing-worktree", exact: true })).toBeVisible()
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const mcp = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = mcp.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
await mcp.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
expect(mock.calls).toEqual([{ type: "mcp", directory: workspace, enabled: true }])
expect(mock.status.get(directory)).toBe("connected")
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
await expect(
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("workspace-plugin", { exact: true }),
).toBeVisible()
})
}
test("non-Git folders show their status without offering worktree actions", async ({ page }) => {
await openDraft(page, "main", { git: false })
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(summary.getByText("No Git", { exact: true })).toBeVisible()
await expect(summary.getByRole("button", { name: "Local repository", exact: true })).toHaveCount(0)
await expect(summary.getByRole("button", { name: "New worktree", exact: true })).toHaveCount(0)
await summary.getByRole("button", { name: "MCP", exact: true }).click()
await expect(
page.getByRole("dialog", { name: "MCP", exact: true }).getByRole("switch", { name: "summary-mcp", exact: true }),
).toBeEnabled()
})
test("new worktree MCP choices persist per draft and apply before the first prompt", async ({ page }, testInfo) => {
const mock = await openDraft(page, "create")
await page.locator('[data-component="composer-editor"]').fill("Use my selected MCPs")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(menu.locator('[data-slot="mcp-preview-hint"]')).toHaveText("Applies when the worktree is created")
await expect(menu).toHaveCSS("opacity", "1")
await testInfo.attach("new-worktree-mcp-preview", { body: await page.screenshot(), contentType: "image/png" })
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).not.toBeChecked()
expect(mock.calls).toEqual([])
await expect
.poll(() =>
page.evaluate(
() =>
JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs") ?? "[]").find(
(tab: { draftID?: string }) => tab.draftID === "draft_summary",
)?.mcp?.states,
),
)
.toEqual({ "summary-mcp": false })
await page.goto(`/new-session?draftId=${secondDraftID}`)
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
await expect(toggle).toBeChecked()
await page.goto(draftPath)
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
await expect(toggle).not.toBeChecked()
expect(mock.calls).toEqual([])
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Use my selected MCPs")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls).toEqual([
{ type: "worktree", directory },
{ type: "mcp", directory: createdWorkspace, enabled: false },
{ type: "session", directory: createdWorkspace },
{ type: "prompt", directory: createdWorkspace },
])
expect(mock.prompts[0].body.text).toBe("Use my selected MCPs")
expect(mock.status.get(directory)).toBe("connected")
})
test("the first prompt waits for a live MCP toggle", async ({ page }) => {
const mock = await openDraft(page)
const release = Promise.withResolvers<void>()
mock.state.hold = release.promise
await page.locator('[data-component="composer-editor"]').fill("Wait for the MCP update")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
try {
await menu.getByText("summary-mcp", { exact: true }).click()
await expect.poll(() => mock.calls.length).toBe(1)
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await page.locator('[data-action="composer-submit"]').click()
await expect(page).toHaveURL(draftPath)
expect(mock.calls).toEqual([{ type: "mcp", directory, enabled: false }])
expect(mock.prompts).toEqual([])
} finally {
release.resolve()
}
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls).toEqual([
{ type: "mcp", directory, enabled: false },
{ type: "session", directory },
{ type: "prompt", directory },
])
})
test("changing worktrees does not wait for another directory's pending MCP update", async ({ page }) => {
const mock = await openDraft(page)
const release = Promise.withResolvers<void>()
mock.state.hold = release.promise
mock.state.holdDirectory = directory
await page.locator('[data-component="composer-editor"]').fill("Run in the selected worktree")
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
try {
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect.poll(() => mock.calls.length).toBe(1)
await page.keyboard.press("Escape")
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
await page.getByRole("menuitem", { name: "Worktree", exact: true }).click()
await page.getByRole("menuitem", { name: "existing-worktree", exact: true }).click()
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).toBeEnabled()
await expect(toggle).not.toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls.find((call) => call.type === "session")?.directory).toBe(workspace)
expect(mock.status.get(directory)).toBe("connected")
} finally {
release.resolve()
}
})
test("failed MCP preparation restores the draft and reuses the created worktree", async ({ page }) => {
const mock = await openDraft(page, "create")
mock.state.fail = true
await page.locator('[data-component="composer-editor"]').fill("Keep this prompt on failure")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
await page.getByRole("dialog", { name: "MCP", exact: true }).getByText("summary-mcp", { exact: true }).click()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await page.locator('[data-action="composer-submit"]').click()
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
await expect(page).toHaveURL(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Keep this prompt on failure")
await expect(page.getByRole("button", { name: "created-worktree", exact: true })).toBeVisible()
expect(mock.prompts).toEqual([])
expect(mock.calls.map((call) => call.type)).toEqual(["worktree", "mcp"])
mock.state.fail = false
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
expect(mock.status.get(createdWorkspace)).toBe("disabled")
expect(mock.prompts[0].body.text).toBe("Keep this prompt on failure")
})
test("new worktree sign-in completes before the draft can send", async ({ page, context }) => {
const mock = await openDraft(page, "create")
mock.status.set(createdWorkspace, "needs_auth")
const attempts: string[] = []
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
await page.route("**/api/integration/**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (route.request().method() === "POST") {
attempts.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
return route.fulfill({
json: { location: { directory: createdWorkspace }, data: { url: "https://auth.example.test/authorize" } },
})
}
return route.fulfill({
json: {
location: { directory: createdWorkspace },
data: {
id: "summary-oauth",
methods: [{ id: "oauth", type: "oauth" }],
},
},
})
})
await page.locator('[data-component="composer-editor"]').fill("Wait for my sign-in")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).not.toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).toBeChecked()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
const popup = page.waitForEvent("popup")
await page.locator('[data-action="composer-submit"]').click()
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
await expect(page).toHaveURL(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Wait for my sign-in")
expect(attempts).toEqual([createdWorkspace])
expect(mock.prompts).toEqual([])
mock.status.set(createdWorkspace, "connected")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
expect(attempts).toHaveLength(1)
})
async function openDraft(page: Page, worktree = "main", options: { git?: boolean; direction?: "ltr" | "rtl" } = {}) {
const project = {
id: "proj_new_summary",
worktree: directory,
name: "summary-project",
vcs: options.git === false ? undefined : "git",
time: { created: 1, updated: 1 },
sandboxes: [workspace],
}
const sessions: ReturnType<typeof currentSession>[] = []
const status = new Map<string, string>([
[directory, "connected"],
[workspace, "disabled"],
[createdWorkspace, "connected"],
])
const calls: { type: string; directory: string; enabled?: boolean }[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
await mockOpenCodeServer(page, {
directory,
project,
sessions,
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "summary-model": { id: "summary-model", name: "Summary Model", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "summary-model" },
},
pageMessages: () => ({ items: [] }),
onPrompt(input) {
const session = sessions.find((session) => session.id === input.sessionID)
if (!session?.location.directory) throw new Error("Prompt arrived before session creation")
calls.push({ type: "prompt", directory: session.location.directory })
prompts.push(input)
},
})
if (options.git === false) {
await page.route(
(url) => url.pathname === "/api/vcs",
(route) => route.fulfill({ json: { location: { directory }, data: { branch: {} } } }),
)
}
await page.route("**/api/mcp**", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
if (route.request().method() === "POST") {
const enabled = url.pathname.endsWith("/connect")
calls.push({ type: "mcp", directory: target, enabled })
if (!state.holdDirectory || state.holdDirectory === target) await state.hold
if (state.fail) return route.fulfill({ status: 500, json: { message: "MCP fixture failed" } })
status.set(target, enabled ? "connected" : "disabled")
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: target },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [
{
name: "summary-mcp",
integrationID: "summary-oauth",
status: { status: status.get(target) ?? "connected" },
},
],
},
})
})
await page.route(
(url) => url.pathname === "/api/location",
(route) =>
route.fulfill({
json: {
directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory,
project: { id: project.id, directory, canonical: directory },
},
}),
)
await page.route(
(url) => url.pathname === "/api/plugin",
(route) => {
const target = new URL(route.request().url()).searchParams.get("location[directory]") ?? directory
const id = target === directory ? "project-plugin" : "workspace-plugin"
return route.fulfill({
json: {
location: { directory: target },
data: [{ id, source: { type: "package", target: id }, features: {}, state: { status: "active" } }],
},
})
},
)
await page.route(
(url) => url.pathname === "/api/skill",
(route) =>
route.fulfill({
json: {
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
data: [
{
id: "summary-skill",
name: "summary-skill",
path: "/skills/summary/SKILL.md",
content: "Summary skill",
},
],
},
}),
)
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
json: [{ type: "document", info: { lsp: { typescript: { command: ["typescript-language-server"] } } } }],
}),
)
await page.route(
(url) => url.pathname === "/api/worktree",
(route) => {
if (route.request().method() !== "POST") return route.fallback()
calls.push({ type: "worktree", directory })
project.sandboxes.push(createdWorkspace)
return route.fulfill({ json: { directory: createdWorkspace } })
},
)
await page.route(
(url) => url.pathname === "/api/session",
(route) => {
if (route.request().method() !== "POST") return route.fallback()
const body: { id: string; location: { directory: string } } = route.request().postDataJSON()
calls.push({ type: "session", directory: body.location.directory })
const session = currentSession(
{ ...body, projectID: project.id, title: "Created summary session" },
body.location.directory,
)
sessions.push(session)
return route.fulfill({ json: { data: session } })
},
)
await page.addInitScript(
({ directory, server, draftID, secondDraftID, worktree }) => {
if (!localStorage.getItem("opencode.global.dat:server"))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs"))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "draft", draftID, server, directory, worktree },
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
]),
)
},
{ directory, server, draftID, secondDraftID, worktree },
)
if (options.direction) await openWithDirection(page, draftPath, options.direction)
if (!options.direction) await page.goto(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-action="composer-model"]')).toContainText("Summary Model")
if (options.git === false) await expect(page.getByText("No Git", { exact: true })).toBeVisible()
if (options.git !== false) {
await expect(
page.getByRole("button", { name: worktree === "create" ? "New worktree" : "Local", exact: true }),
).toBeVisible()
}
return { calls, prompts, status, state }
}
@@ -248,7 +248,7 @@ for (const direction of ["ltr", "rtl"]) {
expect(messageAfter).toEqual(messageBefore)
await page.locator('[data-component="composer-editor"]').pressSequentially("Also: ")
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`Also: ${followUp}`)
expect(mock.calls).toEqual(["worktree", "session", "prompt"])
await expect.poll(() => mock.calls).toEqual(["worktree", "session", "prompt"])
})
}
@@ -0,0 +1,109 @@
import { expect, test } from "@playwright/test"
import type { ProjectUpdateInput } from "@opencode/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/project-autosave"
const project = {
id: "proj_autosave",
canonical: directory,
name: "Autosave project",
icon: { color: "orange" },
commands: { start: "echo setup" },
sandboxes: [],
time: { created: 1, updated: 1 },
}
test.use({ viewport: { width: 1280, height: 900 } })
for (const field of [
{
name: "color",
label: "",
initial: "orange",
next: "blue",
patches: [{ icon: { color: "blue", override: "" } }, { icon: { color: "orange", override: "" } }],
},
{
name: "name",
label: "Project name",
initial: project.name,
next: "Renamed project",
patches: [{ name: "Renamed project" }, { name: project.name }],
},
{
name: "startup script",
label: "Worktree startup script",
initial: project.commands.start,
next: "bun install",
patches: [{ commands: { start: "bun install" } }, { commands: { start: project.commands.start } }],
},
]) {
test(`keeps the final project ${field.name} when reverting during an autosave`, async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project,
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ directory, server }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: [{ type: "http", http: { url: server } }],
projects: {
local: [{ worktree: directory, expanded: true }],
[server]: [{ worktree: directory, expanded: true }],
},
}),
)
},
{ directory, server },
)
const pending = Promise.withResolvers<void>()
const patches: unknown[] = []
await page.route(`**/api/project/${project.id}`, async (route) => {
const patch: Pick<ProjectUpdateInput, "name" | "icon" | "commands"> = route.request().postDataJSON()
patches.push(patch)
if (patches.length === 1) await pending.promise
await route.fulfill({ json: { ...project, ...patch } })
})
await page.goto("/settings")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: project.name, exact: true }).click()
const edit = async (value: string) => {
if (field.name === "color") {
await settings.getByRole("button", { name: `Select ${value} color`, exact: true }).click()
return
}
const input = settings.getByRole("textbox", { name: field.label, exact: true })
await input.fill(value)
await input.blur()
}
const first = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === `/api/project/${project.id}`,
)
await edit(field.next)
await first
await edit(field.initial)
expect(patches).toEqual([field.patches[0]])
pending.resolve()
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
expect(patches).toEqual(field.patches)
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await settings.getByRole("tab", { name: project.name, exact: true }).click()
if (field.name === "color") {
await expect(settings.getByRole("button", { name: "Select orange color", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
)
return
}
await expect(settings.getByRole("textbox", { name: field.label, exact: true })).toHaveValue(field.initial)
})
}
@@ -0,0 +1,129 @@
import { expect, test } from "@playwright/test"
import type { ConfigEntry } from "@opencode/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/configured-lsp"
const entries: ConfigEntry[] = [
{
type: "document",
path: "/config/opencode.json",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx"] },
},
},
},
{
type: "document",
path: `${directory}/opencode.jsonc`,
info: {
lsp: {
typescript: { disabled: true },
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
},
},
},
]
test.use({ viewport: { width: 1280, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_configured_lsp",
canonical: directory,
name: "Configured LSP project",
sandboxes: [],
time: { created: 1, updated: 1 },
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: "Configured LSP project", exact: true }).click()
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
})
test("shows inherited and project-configured LSP entries with config-only status", async ({ page }) => {
const ready = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/config",
async (route) => {
await ready.promise
await route.fulfill({ json: entries })
},
)
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/config")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
expect(new URL((await requested).url()).searchParams.get("location[directory]")).toBe(directory)
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(panel.getByText("Loading", { exact: true })).toBeVisible()
ready.resolve()
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
await expect(panel.getByText("rust", { exact: true })).toBeVisible()
const typescript = panel.locator(".project-settings-extension-row").filter({ hasText: "typescript" })
await expect(typescript).toContainText("Disabled in config")
await expect(typescript).toContainText(".ts, .tsx")
await expect(panel.locator(".project-settings-extension-row").filter({ hasText: "rust" })).toContainText(
"Enabled in config",
)
await expect(panel.getByRole("switch")).toHaveCount(0)
await expect(panel.getByText("Setup required", { exact: true })).toHaveCount(0)
await page.setViewportSize({ width: 390, height: 844 })
await expect(panel.getByText("rust", { exact: true })).toBeInViewport()
await expect
.poll(() => settings.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
})
for (const lsp of [true, false]) {
test(`handles boolean lsp=${lsp} without inventing detected servers`, async ({ page }) => {
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
json: [{ type: "document", info: { lsp } }],
}),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(
panel.getByText(lsp ? "No language servers configured" : "Language servers disabled", { exact: true }),
).toBeVisible()
await expect(panel.locator(".project-settings-extension-row")).toHaveCount(0)
})
}
test("keeps configuration load failures inside the tab and allows retry", async ({ page }) => {
const state = { fail: true }
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
status: state.fail ? 404 : 200,
json: state.fail ? {} : entries,
}),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(panel.getByText("Could not load language server configuration", { exact: true })).toBeVisible()
state.fail = false
await panel.getByRole("button", { name: "Retry", exact: true }).click()
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
await settings.getByRole("tab", { name: "Skills", exact: true }).click()
await expect(settings.getByRole("tabpanel", { name: "Skills", exact: true })).toBeVisible()
})
@@ -34,8 +34,10 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await expect(page.getByRole("button", { name: "Home", exact: true })).toHaveAttribute("aria-pressed", "false")
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
await expect(settings.getByRole("complementary")).toHaveCSS("width", "328px")
await expect(sessionHeading).toBeHidden()
await expect(settings.getByText("Servers", { exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveCount(0)
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
await expect(autoAccept).toBeVisible()
@@ -63,9 +65,21 @@ test("session settings use the remote server context", async ({ page }) => {
},
])
await settings.getByRole("tab", { name: "127.0.0.1:4097", exact: true }).click()
await expect(settings.getByRole("button", { name: "Back to settings", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(settings.getByRole("tab")).toHaveText([
"127.0.0.1:4097",
"Projects",
"Worktrees",
"Providers",
"Models",
"Extensions",
])
await settings.getByRole("tab", { name: "Models" }).click()
await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
await settings.getByRole("button", { name: "Back to settings" }).click()
await settings.getByRole("button", { name: "Back to app" }).click()
await expect(settings).toBeHidden()
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
@@ -73,7 +87,7 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
@@ -382,8 +396,6 @@ async function mockServers(
},
])
}
if (url.pathname === "/api/project/current")
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory })
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active")
@@ -402,6 +414,8 @@ async function mockServers(
directory,
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
})
if (url.pathname === "/api/config/shell") return json(route, [])
if (url.pathname === "/api/websearch/provider") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
@@ -1,5 +1,6 @@
import { base64Encode } from "@opencode/util/encode"
import { expect, test } from "@playwright/test"
import type { OpenCodeEvent } from "@opencode/client/promise"
import { expect, test, type Route } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -13,6 +14,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
test("opens and searches project files inline", async ({ page }) => {
const searches: { query: string; dirs?: string; limit?: number }[] = []
const events: OpenCodeEvent[] = []
await mockOpenCodeServer(page, {
directory,
project: {
@@ -58,6 +60,7 @@ test("opens and searches project files inline", async ({ page }) => {
searches.push(input)
return input.query === "nested" ? ["src/nested.ts"] : []
},
events: () => events.splice(0),
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
@@ -126,6 +129,35 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
const missingReadPattern = "**/api/fs/read/README.md*"
const missingRead = (route: Route) =>
route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({
_tag: "FileNotFoundError",
path: "README.md",
message: "File not found: README.md",
}),
})
await page.route(missingReadPattern, missingRead)
const missingResponse = page.waitForResponse((response) => response.url().includes("/api/fs/read/README.md"))
events.push(filesystemEvent("README.md", "unlink"))
await missingResponse
await expect(page.getByText("Failed to load file", { exact: true })).toBeVisible()
const missingTab = panel.getByRole("tab", { name: "File not found: README.md", selected: true })
await expect(missingTab).toBeVisible()
await expect(missingTab.locator("[data-file-not-found]")).toHaveCSS("text-decoration-line", "line-through")
await expect(panel.getByText("File not found: README.md", { exact: true })).toBeVisible()
await page.unroute(missingReadPattern, missingRead)
events.push(filesystemEvent("README.md", "add"))
const restoredTab = panel.getByRole("tab", { name: "README.md", selected: true })
await expect(restoredTab).toBeVisible()
await expect(restoredTab.locator("[data-file-not-found]")).toHaveCount(0)
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
await expect(sidebar).toBeVisible()
@@ -155,6 +187,16 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
})
function filesystemEvent(file: string, event: "add" | "change" | "unlink"): OpenCodeEvent {
return {
id: `evt_${file}_${event}`,
created: 1,
type: "filesystem.changed",
location: { directory },
data: { file, event },
}
}
function fileNode(path: string) {
return {
name: path,
@@ -16,8 +16,8 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
})
}
if (url.pathname === "/api/global/health" || url.pathname === "/api/health") {
return json(route, { healthy: true, version: "2.0.0" })
if (url.pathname === "/api/status") {
return json(route, { version: "2.0.0", pid: 1, urls: [url.origin] })
}
return json(route, {})
})
@@ -27,8 +27,12 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(0)
await settings.getByRole("tab", { name: "Servers" }).click()
await settings.getByRole("button", { name: "Add server" }).click()
const add = settings.getByRole("button", { name: "Add server" })
const group = settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" })
await expect(add).toHaveCSS("opacity", "0")
await group.hover()
await expect(add).toHaveCSS("opacity", "1")
await add.click()
const editor = page.getByRole("dialog", { name: "Add server" })
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
@@ -4,7 +4,7 @@ import { installStressSessionTabs, stressSessionHref } from "../performance/time
import { mockOpenCodeServer } from "../utils/mock-server"
for (const direction of ["ltr", "rtl"] as const) {
test(`session header groups controls and exposes server status in ${direction}`, async ({ page }) => {
test(`session header groups controls and exposes session details in ${direction}`, async ({ page }) => {
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
@@ -31,7 +31,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(review).toBeVisible()
await expect(details).toBeVisible()
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
await expect(status).toBeVisible()
await expect(status).toHaveCount(0)
const titleBounds = await header.getByRole("heading").boundingBox()
expect(titleBounds).not.toBeNull()
for (const editing of [false, true]) {
@@ -122,12 +122,11 @@ for (const direction of ["ltr", "rtl"] as const) {
.toBe(true)
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
await status.click()
const mcp = page.getByRole("tab", { name: "MCP", exact: true })
const plugins = page.getByRole("tab", { name: "Plugins", exact: true })
await expect(mcp).toHaveAttribute("aria-selected", "true")
await plugins.click()
await expect(plugins).toHaveAttribute("aria-selected", "true")
await details.click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
await expect(mcp).toBeVisible()
await expect(summary.getByRole("button", { name: "Plugins", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(mcp).toBeHidden()
})
@@ -55,6 +55,8 @@ for (const direction of ["ltr", "rtl"] as const) {
"href",
`#opencode-v2-icon-${workspace ? "outline-worktree" : "monitor"}`,
)
// Initial layout scrolls this sticky header's ancestor and dismisses its tooltip.
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
const background = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
await trigger.hover()
await expect(trigger).not.toHaveCSS("background-color", background)
@@ -198,14 +200,15 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(settings).toBeFocused()
await expect(page.getByRole("tooltip")).toBeHidden()
await page.keyboard.press("Enter")
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("heading", { name: copy["dialog.project.edit.title"], exact: true })).toBeVisible()
await expect(dialog.getByRole("textbox", { name: copy["dialog.project.edit.name"], exact: true })).toHaveValue(
project.name,
)
const settingsScreen = page.getByTestId("settings-screen")
await expect(settingsScreen.getByRole("heading", { name: project.name, exact: true })).toBeVisible()
await expect(
settingsScreen.getByRole("textbox", { name: en["project.settings.name.title"], exact: true }),
).toHaveValue(project.name)
await expect(menu).toBeHidden()
await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click()
await expect(dialog).toBeHidden()
await settingsScreen.getByRole("button", { name: en["settings.backToProjects"], exact: true }).click()
await settingsScreen.getByRole("button", { name: en["settings.backToApp"], exact: true }).click()
await expect(settingsScreen).toBeHidden()
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await page.setViewportSize({ width: 1440, height: 900 })
@@ -272,12 +275,13 @@ for (const state of ["closed", "unopened"] as const) {
await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled()
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
await menu.getByRole("menuitem", { name: "Edit project", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox", { name: en["dialog.project.edit.name"], exact: true })).toHaveValue(
fixture.project.name,
)
await dialog.getByRole("button", { name: en["common.cancel"], exact: true }).click()
await expect(dialog).toBeHidden()
const settingsScreen = page.getByTestId("settings-screen")
await expect(
settingsScreen.getByRole("textbox", { name: en["project.settings.name.title"], exact: true }),
).toHaveValue(fixture.project.name)
await settingsScreen.getByRole("button", { name: en["settings.backToProjects"], exact: true }).click()
await settingsScreen.getByRole("button", { name: en["settings.backToApp"], exact: true }).click()
await expect(settingsScreen).toBeHidden()
}
await trigger.click()
await menu.getByRole("menuitem", { name: fixture.project.name, exact: true }).click()
@@ -0,0 +1,99 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode/util/encode"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
const server = "http://summary-remote.test:4096"
const path = "/home/remote/.config/opencode/opencode.jsonc"
test.use({ serviceWorkers: "block", permissions: ["clipboard-read", "clipboard-write"] })
for (const service of [
{ name: "MCP", config: { mcp: { servers: {} } } },
{ name: "Plugins", config: { plugins: [] } },
{ name: "Skills", config: { skills: [] } },
{ name: "LSP", config: { lsp: false } },
]) {
test(`remote ${service.name} copies its configuration path with timeline copy feedback`, async ({ page }) => {
await setup(page)
await page.route("**/api/config**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: [
{ type: "document", path, info: service.config },
{ type: "document", path: `${fixture.directory}/opencode.json`, info: {} },
{ type: "document", path: `${fixture.directory}/.opencode/agents/review.md`, info: {} },
],
})
})
await page.goto(`/server/${base64Encode(server)}/session/${fixture.targetID}`)
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page
.getByRole("dialog", { name: "Session details", exact: true })
.getByRole("button", { name: service.name, exact: true })
.click()
const menu = page.getByRole("dialog", { name: service.name, exact: true })
const copy = menu.getByRole("button", { name: "Copy configuration file path", exact: true })
const tooltipOffset = async () => {
const icon = await copy.locator("svg").boundingBox()
const tooltip = await page.getByRole("tooltip").boundingBox()
if (!icon || !tooltip) return Infinity
return Math.abs(tooltip.x + tooltip.width / 2 - icon.x - icon.width / 2)
}
await expect(copy).toBeEnabled()
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-outline-copy")
await expect(copy.locator(".session-service-config-arrow")).toHaveCount(0)
await copy.hover()
await expect(page.getByRole("tooltip")).toHaveText("Copy")
await expect.poll(tooltipOffset).toBeLessThanOrEqual(1)
await copy.click()
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(path)
await expect(page.getByRole("tooltip")).toHaveText("Copied")
await expect.poll(tooltipOffset).toBeLessThanOrEqual(1)
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-check")
await expect(menu).toBeVisible()
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-outline-copy")
})
}
test("remote configuration without a file path reports the problem instead of copying a directory", async ({
page,
}) => {
await setup(page)
await page.goto(`/server/${base64Encode(server)}/session/${fixture.targetID}`)
await page.evaluate(() => navigator.clipboard.writeText("original clipboard"))
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page
.getByRole("dialog", { name: "Session details", exact: true })
.getByRole("button", { name: "Skills", exact: true })
.click()
const copy = page
.getByRole("dialog", { name: "Skills", exact: true })
.getByRole("button", { name: "Copy configuration file path", exact: true })
await copy.click()
await expect(page.getByText("No configuration file found", { exact: true })).toBeVisible()
await expect(copy).toBeEnabled()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("original clipboard")
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-outline-copy")
})
async function setup(page: Page) {
await mockOpenCodeServer(page, {
server,
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.addInitScript((server) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: [{ type: "http", http: { url: server }, displayName: "Remote server" }],
projects: {},
lastProject: {},
}),
)
}, server)
}
@@ -0,0 +1,225 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { openWithDirection } from "../utils/direction"
for (const direction of ["ltr", "rtl"] as const) {
test(`summary slides the conversation into spare space and back in ${direction}`, async ({ page }, testInfo) => {
await page.clock.install({ time: new Date("2026-09-10T12:00:00Z") })
await page.setViewportSize({ width: 1440, height: 900 })
await mockStressTimeline(page)
await openWithDirection(page, stressSessionHref(fixture.targetID), direction)
const trigger = page.getByRole("button", { name: "Session details", exact: true })
await expect(trigger).toBeEnabled()
await expect(page.locator("html")).toHaveAttribute("dir", direction)
await expect(page.locator("html")).toHaveAttribute("lang", "en")
const row = page.locator(
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
)
const content = page.locator("[data-timeline-virtual-content]")
const composer = page.locator('[data-component="session-composer-dock"] > div')
const panel = page.locator('[data-slot="session-chat-panel"]')
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(row).toBeInViewport()
await expect(row).toHaveCSS("width", "1000px")
const before = await row.boundingBox()
const dock = await composer.boundingBox()
expect(before).not.toBeNull()
expect(dock).not.toBeNull()
await content.evaluate((element) => {
element.setAttribute("data-summary-motion", "")
for (const type of ["transitionrun", "transitionend"]) {
element.addEventListener(type, (event) => {
if (event.target !== element || (event as TransitionEvent).propertyName !== "translate") return
element.setAttribute("data-summary-motion", `${element.getAttribute("data-summary-motion")}${type},`)
})
}
})
await testInfo.attach(`summary-${direction}-centered`, { body: await page.screenshot(), contentType: "image/png" })
await trigger.click()
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
await expect
.poll(async () => {
const message = await row.boundingBox()
const details = await summary.boundingBox()
const chat = await panel.boundingBox()
if (!message || !details || !chat) return false
return (
message.x >= chat.x &&
message.x + message.width <= chat.x + chat.width &&
(direction === "ltr" ? message.x + message.width < details.x : message.x > details.x + details.width)
)
})
.toBe(true)
await expect(row).toHaveCSS("width", "1000px")
await expect
.poll(async () => {
const message = await row.boundingBox()
const input = await composer.boundingBox()
if (!message || !input || !before || !dock) return Infinity
return Math.abs(message.x - before.x - (input.x - dock.x))
})
.toBeLessThan(1)
await expect
.poll(() =>
content.evaluate((element) => element.parentElement!.scrollWidth - element.parentElement!.clientWidth),
)
.toBe(0)
await testInfo.attach(`summary-${direction}-shifted`, { body: await page.screenshot(), contentType: "image/png" })
await page.clock.pauseAt(new Date("2026-09-10T12:01:00Z"))
const shifted = await content.evaluate((element) => getComputedStyle(element).translate)
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
// Keep issuing resize events before the idle timer expires, including crossing the width cutoff.
for (const width of [1520, 1280, 1600]) {
await page.setViewportSize({ width, height: 900 })
await expect(panel).toHaveAttribute("data-summary-resizing", "true")
await page.clock.runFor(100)
await expect(content).toHaveCSS("translate", shifted)
await expect(composer).toHaveCSS("translate", shifted)
await expect(content).toHaveAttribute("data-summary-motion", "")
}
await page.clock.resume()
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
await expect(content).not.toHaveCSS("translate", shifted)
await page.setViewportSize({ width: 1440, height: 900 })
await expect(content).toHaveCSS("translate", shifted)
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
await page.keyboard.press("Escape")
await expect(summary).toBeHidden()
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
await expect.poll(async () => Math.abs((await row.boundingBox())!.x - before!.x)).toBeLessThan(1)
await expect(trigger).toBeFocused()
await trigger.click()
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
// Cross the actual chat-panel breakpoint, including any surrounding shell width.
const shell = 1440 - (await panel.boundingBox())!.width
await page.setViewportSize({ width: 1320 + shell, height: 900 })
await expect.poll(async () => (await panel.boundingBox())!.width).toBe(1320)
await expect
.poll(async () => {
const message = (await row.boundingBox())!
const details = (await summary.boundingBox())!
return direction === "ltr" ? details.x - message.x - message.width : message.x - details.x - details.width
})
.toBeGreaterThan(0)
await page.setViewportSize({ width: 1319 + shell, height: 900 })
await expect(content).toHaveCSS("translate", "none")
await expect(row).toHaveCSS("width", "1000px")
await expect(summary).toBeVisible()
await page.setViewportSize({ width: 1800, height: 900 })
await expect(content).toHaveCSS("translate", "0px")
await expect(summary).toBeVisible()
await page.emulateMedia({ reducedMotion: "reduce" })
await page.setViewportSize({ width: 1440, height: 900 })
await expect(content).toHaveCSS("transition-duration", "0s")
await page.keyboard.press("Escape")
await expect(content).toHaveCSS("translate", "none")
await expect.poll(async () => Math.abs((await row.boundingBox())!.x - before!.x)).toBeLessThan(1)
})
}
for (const direction of ["ltr", "rtl"] as const) {
test(`summary truncates long copy before the indicator column in ${direction}`, async ({ page }) => {
const branch = `feature/${"long-branch-name-".repeat(12)}`
await mockStressTimeline(page)
await page.route(
(url) => url.pathname === "/api/vcs",
(route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: { location: { directory: fixture.directory }, data: { branch: { current: branch, default: "main" } } },
})
},
)
await openWithDirection(page, stressSessionHref(fixture.targetID), direction)
await expect(page.locator("html")).toHaveAttribute("dir", direction)
await expect(page.locator("html")).toHaveAttribute("lang", "en")
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const text = summary.getByText(branch, { exact: true })
await expect(text).toBeVisible()
await expect(text).toHaveCSS("text-overflow", "ellipsis")
await expect.poll(() => text.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
const arrow = summary
.getByRole("button", { name: "Local repository", exact: true })
.locator(".session-summary-menu-indicator")
await expect
.poll(async () => {
const label = await text.boundingBox()
const icon = await arrow.boundingBox()
if (!label || !icon) return 0
return direction === "ltr" ? icon.x - label.x - label.width : label.x - icon.x - icon.width
})
.toBeGreaterThanOrEqual(12)
for (const name of ["Local repository", "MCP", "Plugins", "Skills", "LSP"]) {
const row = summary.getByRole("button", { name, exact: true })
await expect
.poll(async () => {
const label = await row.locator(".session-summary-label").boundingBox()
const icon = await row.locator(".session-summary-menu-indicator").boundingBox()
if (!label || !icon) return 0
return direction === "ltr" ? icon.x - label.x - label.width : label.x - icon.x - icon.width
})
.toBeGreaterThanOrEqual(12)
}
})
}
for (const theme of ["light", "dark"] as const) {
test(`summary bounds long service lists in ${theme}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width: 800, height: 600 })
await mockStressTimeline(page)
await page.addInitScript((theme) => {
localStorage.setItem("opencode-theme-id", "oc-2")
localStorage.setItem("opencode-color-scheme", theme)
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: theme === "dark" ? "he" : "en" }))
}, theme)
await page.route("**/api/mcp**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
new URL(route.request().url()).pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: Array.from({ length: 30 }, (_, index) => ({
name: `server-${String(index).padStart(2, "0")}-בדיקה-${"long-name-".repeat(6)}`,
status: { status: "connected" },
})),
},
})
})
await page.goto(stressSessionHref(fixture.targetID))
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", theme)
await page.getByRole("button", { name: theme === "dark" ? "פרטי ההפעלה" : "Session details", exact: true }).click()
const summary = page.locator('[data-component="session-summary-panel"]')
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(menu.getByRole("switch")).toHaveCount(30)
await expect.poll(() => menu.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
await expect
.poll(async () => {
const bounds = await menu.boundingBox()
return (
!!bounds &&
bounds.x >= 15 &&
bounds.y >= 15 &&
bounds.x + bounds.width <= 785 &&
bounds.y + bounds.height <= 585
)
})
.toBe(true)
await testInfo.attach(`summary-${theme}-long-list`, { body: await page.screenshot(), contentType: "image/png" })
await menu.getByRole("switch", { name: /^server-29-/ }).focus()
await expect(menu.getByRole("switch", { name: /^server-29-/ })).toBeFocused()
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeFocused()
})
}
@@ -0,0 +1,170 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test("every MCP row hit area toggles exactly once and keeps the submenu open", async ({ page }, testInfo) => {
await mockStressTimeline(page)
const state = { enabled: true }
const writes: string[] = []
await page.route("**/api/mcp**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const directory = url.searchParams.get("location[directory]")
if (route.request().method() === "POST") {
expect(directory).toBe(fixture.directory)
writes.push(url.pathname)
state.enabled = url.pathname.endsWith("/connect")
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [
{ name: "figma", status: { status: state.enabled ? "connected" : "disabled" } },
{ name: "linear", status: { status: "needs_auth" }, integrationID: "linear-oauth" },
{ name: "playwright", status: { status: "failed", error: "Connection refused" } },
{ name: "waiting", status: { status: "pending" } },
],
},
})
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = submenu.getByRole("switch", { name: "figma", exact: true })
const row = submenu
.locator('[data-component="switch"]')
.filter({ has: page.getByRole("switch", { name: "figma", exact: true }) })
await expect(toggle).toBeChecked()
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toBeChecked()
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toHaveAccessibleDescription("Failed")
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toBeDisabled()
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toHaveAccessibleDescription("Connecting…")
await expect(submenu.getByRole("switch", { name: "linear", exact: true })).toHaveAccessibleDescription(
"Sign in required",
)
await testInfo.attach("summary-mcp-states", { body: await page.screenshot(), contentType: "image/png" })
for (const [index, target] of ["label", "dot", "padding", "control", "keyboard"].entries()) {
const enabled = index % 2 !== 0
await expect(toggle).toBeEnabled()
if (target === "label") await row.getByText("figma", { exact: true }).click()
if (target === "dot") await row.locator(".session-service-dot").click()
if (target === "padding") await row.click({ position: { x: 3, y: 3 } })
if (target === "control") await row.locator('[data-slot="switch-control"]').click()
if (target === "keyboard") await toggle.press("Space")
await expect(toggle).toBeChecked({ checked: enabled })
await expect(toggle).toBeEnabled()
await expect(submenu).toBeVisible()
if (target === "keyboard") await expect(toggle).toBeFocused()
expect(writes).toHaveLength(index + 1)
expect(writes[index]).toBe(`/api/mcp/figma/${enabled ? "connect" : "disconnect"}`)
}
})
test("MCP authentication starts before a slow resource catalog finishes", async ({ page, context }) => {
await mockStressTimeline(page)
const state = { status: "disabled" }
const attempts: string[] = []
const resources = Promise.withResolvers<void>()
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
await page.route("**/api/mcp**", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
if (url.pathname.endsWith("/connect")) {
state.status = "needs_auth"
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/resource" && state.status === "needs_auth") await resources.promise
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [{ name: "linear", integrationID: "linear-oauth", status: { status: state.status } }],
},
})
})
await page.route("**/api/integration/**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (route.request().method() === "POST") {
attempts.push(route.request().url())
return route.fulfill({
json: { location: { directory: fixture.directory }, data: { url: "https://auth.example.test/authorize" } },
})
}
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: {
id: "linear-oauth",
methods: [{ id: "oauth", type: "oauth" }],
},
},
})
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = submenu.getByRole("switch", { name: "linear", exact: true })
await expect(toggle).toBeEnabled()
const refresh = page.waitForRequest(
(request) =>
state.status === "needs_auth" &&
new URL(request.url()).pathname === "/api/mcp/resource" &&
request.method() === "GET",
)
try {
const popup = page.waitForEvent("popup")
await submenu.getByText("linear", { exact: true }).click()
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
await refresh
await expect(toggle).toBeChecked()
await expect(toggle).toHaveAccessibleDescription("Sign in required")
} finally {
resources.resolve()
}
await expect(toggle).toBeEnabled()
expect(attempts).toHaveLength(1)
expect(new URL(attempts[0]).searchParams.get("location[directory]")).toBe(fixture.directory)
})
test("multiple desktop connections show the session's server name", async ({ page }) => {
await mockStressTimeline(page)
await page.route("http://secondary.test/**", (route) =>
route.fulfill({ json: { version: "2.0.0", pid: 1, urls: ["http://secondary.test"] } }),
)
await page.addInitScript(
({ directory, server }) => {
const current = { type: "http", http: { url: server }, displayName: "Design server" }
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: [current, { type: "http", http: { url: "http://secondary.test" }, displayName: "Other server" }],
projects: { local: [{ worktree: directory, expanded: true }] },
hidden: {},
lastProject: {},
recentlyClosed: {},
}),
)
},
{
directory: fixture.directory,
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
},
)
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(summary.getByRole("button", { name: "Design server", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toHaveCount(0)
})
@@ -0,0 +1,126 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
const services = [
{ name: "MCP", path: "/api/mcp", empty: "No MCP servers configured", item: "summary-mcp" },
{ name: "Plugins", path: "/api/plugin", empty: "No plugins configured", item: "summary-plugin" },
{ name: "Skills", path: "/api/skill", empty: "No skills configured", item: "summary-skill" },
{ name: "LSP", path: "/api/config", empty: "No LSP servers configured", item: "summary-lsp" },
] as const
for (const service of services) {
for (const empty of [false, true]) {
test(`${service.name} keeps ${empty ? "its empty state" : "cached items"} visible while reopening and refreshing`, async ({
page,
}, testInfo) => {
await mockStressTimeline(page)
const warnings: string[] = []
page.on("console", (event) => {
if (event.text().includes("computations created outside")) warnings.push(event.text())
})
const state = { hold: false }
const response = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === service.path,
async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (state.hold) await response.promise
if (service.name === "LSP")
return route.fulfill({
json: empty ? [] : [{ type: "document", info: { lsp: { "summary-lsp": { command: ["summary-lsp"] } } } }],
})
const items =
service.name === "MCP"
? [{ name: service.item, status: { status: "connected" } }]
: service.name === "Plugins"
? [
{
id: service.item,
source: { type: "package", target: service.item },
features: {},
state: { status: "active" },
},
]
: [{ id: service.item, name: service.item, path: "/skills/summary/SKILL.md", content: "Summary" }]
return route.fulfill({ json: { location: { directory: fixture.directory }, data: empty ? [] : items } })
},
)
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const trigger = summary.getByRole("button", { name: service.name, exact: true })
await trigger.click()
const menu = page.getByRole("dialog", { name: service.name, exact: true })
const content = menu.getByText(empty ? service.empty : service.item, { exact: true })
await expect(content).toBeVisible()
await expect(menu).toHaveAttribute("aria-busy", "false")
await expect(menu).toHaveCSS("width", empty ? "232px" : "280px")
if (empty) {
const message = menu.locator(".session-service-empty")
await expect(message).toHaveCSS("padding", "0px")
await expect(message).toHaveCSS("gap", "0px")
await expect(message.locator("strong")).toHaveCSS("padding", "8px 12px")
await expect(message).toHaveCSS("font-size", "13px")
await expect(message).toHaveCSS("line-height", "16px")
await expect(message.locator("strong")).toHaveCSS("font-weight", "530")
await expect(message.locator(".session-service-footer")).toHaveCSS("font-weight", "440")
await testInfo.attach(`${service.name}-empty`, { body: await menu.screenshot(), contentType: "image/png" })
}
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
state.hold = true
const refresh = page.waitForRequest(
(request) => request.method() === "GET" && new URL(request.url()).pathname === service.path,
)
try {
await trigger.click()
await refresh
await expect(menu).toHaveAttribute("aria-busy", "true")
await expect(content).toBeVisible()
await expect(menu.getByRole("status")).toHaveCount(0)
await expect(menu).toHaveCSS("width", empty ? "232px" : "280px")
await expect(summary).toBeVisible()
} finally {
response.resolve()
}
await expect(menu).toHaveAttribute("aria-busy", "false")
await expect(content).toBeVisible()
expect(warnings).toEqual([])
})
}
}
test("prefetching plugins does not suspend the summary or report an empty catalog", async ({ page }) => {
await mockStressTimeline(page)
const response = Promise.withResolvers<void>()
const state = { requested: false }
await page.route(
(url) => url.pathname === "/api/plugin",
async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
state.requested = true
await response.promise
return route.fulfill({ json: { location: { directory: fixture.directory }, data: [] } })
},
)
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
try {
await expect.poll(() => state.requested).toBe(true)
await expect(summary.getByRole("button", { name: fixture.project.name, exact: true })).toBeVisible()
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
const menu = page.getByRole("dialog", { name: "Plugins", exact: true })
await expect(menu.getByRole("status")).toContainText("Loading")
await expect(menu.getByText("No plugins configured", { exact: true })).toHaveCount(0)
await expect(summary).toBeVisible()
} finally {
response.resolve()
}
await expect(
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("No plugins configured", { exact: true }),
).toBeVisible()
})
@@ -0,0 +1,296 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import {
installStressSessionTabs,
mockStressTimeline,
stressSessionHref,
} from "../performance/timeline/timeline-test-helpers"
import { openWithDirection } from "../utils/direction"
for (const custom of [false, true]) {
test(`summary tooltip and ${custom ? "custom" : "default"} shortcut follow the active session`, async ({ page }) => {
await mockStressTimeline(page)
await installStressSessionTabs(page)
if (custom) {
await page.addInitScript(() => {
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({ ...settings, keybinds: { ...settings.keybinds, "session.summary.toggle": "f8" } }),
)
})
}
await page.goto(stressSessionHref(fixture.sourceID))
const trigger = page.getByRole("button", { name: "Session details", exact: true })
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(trigger).toBeEnabled()
await trigger.hover()
const tooltip = page.getByRole("tooltip")
await expect(tooltip).toBeVisible()
await expect(tooltip).toContainText("Summary")
const mac = await page.evaluate(() => /(Mac|iPod|iPhone|iPad)/.test(navigator.platform))
const shortcut = custom ? "F8" : mac ? "Meta+Shift+Y" : "Control+Shift+Y"
await expect(tooltip.locator('[data-slot="keybind-v2-label"]')).toHaveText(
custom ? ["F8"] : mac ? ["⇧", "⌘", "Y"] : ["Ctrl", "Shift", "Y"],
)
for (const id of [fixture.sourceID, fixture.targetID, fixture.sourceID]) {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(id)}"]`).click()
await expect(
page.locator(
`[data-timeline-row="UserMessage"][data-message-id="${id === fixture.sourceID ? fixture.expected.sourceMessageIDs.at(-1) : fixture.expected.targetMessageIDs.at(-1)}"]`,
),
).toBeInViewport()
await page.keyboard.press(shortcut)
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
await expect.poll(() => summary.evaluate((element) => element.contains(document.activeElement))).toBe(true)
await expect(tooltip).toBeHidden()
await page.keyboard.press(shortcut)
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(summary).toBeHidden()
await expect(trigger).toBeFocused()
}
})
}
for (const layout of ["horizontal", "vertical"] as const) {
test(`summary persists both disclosures across sessions with ${layout} tabs`, async ({ page }, testInfo) => {
await mockStressTimeline(page)
await page.addInitScript((layout) => {
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({
...settings,
appearance: { ...settings.appearance, tabLayout: layout },
general: { ...settings.general, showStatus: true },
}),
)
}, layout)
await page.goto(stressSessionHref(fixture.targetID))
const trigger = page.getByRole("button", { name: "Session details", exact: true })
await expect(trigger).toBeEnabled()
await expect(page.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
await trigger.click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const project = summary.getByRole("button", { name: fixture.project.name, exact: true })
const server = summary.getByRole("button", { name: "Extensions", exact: true })
await expect(project).toHaveAttribute("aria-expanded", "true")
await expect(server).toHaveAttribute("aria-expanded", "true")
for (const heading of [project, server]) {
await expect(heading).toHaveCSS("column-gap", "8px")
await expect(heading.locator(".session-summary-label")).toHaveCSS("flex-grow", "0")
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("width", "14")
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("height", "14")
}
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await testInfo.attach(`summary-${layout}`, { body: await page.screenshot(), contentType: "image/png" })
await project.click()
await expect(project).toHaveAttribute("aria-expanded", "false")
await expect(summary.getByRole("button", { name: "No changes", exact: true })).toHaveCount(0)
await expect(server).toHaveAttribute("aria-expanded", "true")
await server.click()
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(summary).toBeHidden()
await expect(trigger).toBeFocused()
await trigger.click()
await expect(project).toHaveAttribute("aria-expanded", "false")
await expect(server).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").sessionSummary))
.toEqual({ projectExpanded: false, serverExpanded: false })
await page.goto(stressSessionHref(fixture.sourceID))
await trigger.click()
await expect(project).toHaveAttribute("aria-expanded", "false")
await expect(server).toHaveAttribute("aria-expanded", "false")
await server.click()
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await expect(project).toHaveAttribute("aria-expanded", "false")
await page.keyboard.press("Escape")
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(settings.getByText("Server status", { exact: true })).toHaveCount(0)
})
}
for (const direction of ["ltr", "rtl"] as const) {
test(`service submenus open on click and stay aligned with the view in ${direction}`, async ({ page }, testInfo) => {
await mockStressTimeline(page)
await openWithDirection(page, stressSessionHref(fixture.targetID), direction)
await expect(page.getByRole("button", { name: "Session details", exact: true })).toBeEnabled()
await expect(page.locator("html")).toHaveAttribute("dir", direction)
await expect(page.locator("html")).toHaveAttribute("lang", "en")
const warnings: string[] = []
page.on("console", (event) => {
if (event.text().includes("computations created outside")) warnings.push(event.text())
})
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
await mcp.hover()
await expect(submenu).toHaveCount(0)
await mcp.click()
await expect(submenu.getByText("No MCP servers configured", { exact: true })).toBeVisible()
await expect(summary).toBeVisible()
await expect
.poll(async () => {
const row = await mcp.boundingBox()
const menu = await submenu.boundingBox()
if (!row || !menu) return false
return direction === "ltr" ? menu.x + menu.width <= row.x : menu.x >= row.x + row.width
})
.toBe(true)
await testInfo.attach(`summary-submenu-${direction}`, { body: await page.screenshot(), contentType: "image/png" })
await page.keyboard.press("Escape")
await expect(submenu).toBeHidden()
await expect(summary).toBeVisible()
await expect(mcp).toBeFocused()
await mcp.press("Enter")
await expect(submenu.getByText("Configuration file")).toBeVisible()
await mcp.click()
await expect(submenu).toBeHidden()
for (const [name, text] of [
["Plugins", "No plugins configured"],
["Skills", "No skills configured"],
["LSP", "No LSP servers configured"],
]) {
await summary.getByRole("button", { name, exact: true }).click()
await expect(page.getByRole("dialog", { name, exact: true }).getByText(text, { exact: true })).toBeVisible()
await expect(submenu).toBeHidden()
}
await summary.getByRole("button", { name: "Extensions", exact: true }).click()
await expect(page.getByRole("dialog", { name: "LSP", exact: true })).toBeHidden()
await page.keyboard.press("Escape")
await expect(summary).toBeHidden()
for (const reviewOpen of [false, true]) {
if (reviewOpen) await page.getByRole("button", { name: "Toggle review", exact: true }).click()
await page.getByRole("button", { name: "Session details", exact: true }).click()
await expect(summary).toBeVisible()
await expect
.poll(async () => {
const header = await page.locator("[data-session-title]").boundingBox()
const panel = await summary.boundingBox()
if (!header || !panel) return Infinity
return direction === "ltr"
? Math.abs(header.x + header.width - panel.x - panel.width - 12)
: Math.abs(panel.x - header.x - 12)
})
.toBeLessThanOrEqual(1)
await page.keyboard.press("Escape")
}
expect(warnings).toEqual([])
})
}
test("catalog submenus show project plugins and skills, refresh on reopen, and distinguish errors from empty", async ({
page,
}, testInfo) => {
await mockStressTimeline(page)
const state = { fail: true, extra: false }
const requests: string[] = []
await page.route("**/api/plugin**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
requests.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
if (state.fail) return route.fulfill({ status: 500, json: { message: "Unavailable" } })
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: [
{ id: "builtin", source: { type: "builtin" }, features: {}, state: { status: "active" } },
{
id: "supermemory",
source: { type: "package", target: "opencode-supermemory" },
features: { server: true },
state: { status: "active" },
},
{
id: "broken-plugin",
source: { type: "local", path: "/broken.ts" },
features: { server: true },
state: { status: "failed", error: "Plugin failed to activate" },
},
...(state.extra
? [
{
id: "daytona",
source: { type: "package", target: "opencode-daytona" },
features: {},
state: { status: "active" },
},
]
: []),
],
},
})
})
await page.route("**/api/skill**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: [
{ id: "find-skills", name: "find-skills", path: "/skills/find/SKILL.md", content: "Find skills" },
{
id: "review-animations",
name: "review-animations",
path: "/skills/review/SKILL.md",
content: "Review animations",
},
],
},
})
})
await page.route("**/api/config**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: [
{
type: "document",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"] },
rust: { command: ["rust-analyzer"] },
},
},
},
{ type: "document", info: { lsp: { rust: { disabled: true } } } },
],
})
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
const plugins = page.getByRole("dialog", { name: "Plugins", exact: true })
await expect(plugins.getByRole("alert")).toContainText("Request failed")
await expect(plugins.getByText("No plugins configured", { exact: true })).toHaveCount(0)
state.fail = false
await plugins.getByRole("button", { name: "Retry", exact: true }).click()
await expect(plugins.getByText("supermemory", { exact: true })).toBeVisible()
await expect(plugins.getByText("builtin", { exact: true })).toHaveCount(0)
await expect(plugins.getByTitle("Plugin failed to activate")).toContainText("Failed")
expect(requests.every((directory) => directory === fixture.directory)).toBe(true)
await testInfo.attach("summary-plugins", { body: await page.screenshot(), contentType: "image/png" })
await page.keyboard.press("Escape")
state.extra = true
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
await expect(plugins.getByText("daytona", { exact: true })).toBeVisible()
await summary.getByRole("button", { name: "Skills", exact: true }).click()
const skills = page.getByRole("dialog", { name: "Skills", exact: true })
await expect(skills.getByText("find-skills", { exact: true })).toBeVisible()
await expect(skills.getByText("review-animations", { exact: true })).toBeVisible()
await expect(plugins).toBeHidden()
await summary.getByRole("button", { name: "LSP", exact: true }).click()
const lsp = page.getByRole("dialog", { name: "LSP", exact: true })
await expect(lsp.getByText("Configured LSPs", { exact: true })).toBeVisible()
await expect(lsp.getByText("typescript", { exact: true })).toBeVisible()
await expect(lsp.getByText("rust", { exact: true })).toHaveCount(0)
await expect(lsp.locator(".session-service-dot")).toHaveCount(0)
await testInfo.attach("summary-configured-lsp", { body: await page.screenshot(), contentType: "image/png" })
})
@@ -109,11 +109,11 @@ test("passes through non-event fetches", async ({ page }) => {
const timeline = await setupTimeline(page)
const health = await page.evaluate(async () => {
const response = await fetch("/api/health")
const response = await fetch("/api/status")
return response.json()
})
expect(health).toEqual({ healthy: true, version: "2.0.0", pid: 1 })
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: ["http://localhost"] })
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
})
@@ -13,11 +13,25 @@ test.beforeEach(async ({ page }) => {
id: "proj_settings_demo",
canonical: directory,
name: "Settings demo",
icon: {
color: "orange",
override:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Crect width='16' height='16' fill='red'/%3E%3C/svg%3E",
},
commands: { start: "echo setup" },
vcs: "git",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes,
},
provider: { all: [], connected: [], default: {} },
configEntries: [
{ type: "document", path: "/home/test/.config/opencode/opencode.jsonc", info: { shell: "/bin/zsh" } },
{ type: "directory", path: "/home/test/.config/opencode" },
],
shells: [
{ path: "/bin/zsh", name: "zsh", acceptable: true },
{ path: "/bin/bash", name: "bash", acceptable: true },
],
sessions: sandboxes.map((directory, index) => ({
id: `ses_settings_${index + 1}`,
title: `Workspace ${index + 1} session`,
@@ -56,6 +70,113 @@ test("settings has its own route and returns through app history", async ({ page
await expect(home).toHaveAttribute("aria-pressed", "true")
})
test("single-server settings expose scoped pages without a server picker", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Servers", exact: true })).toHaveCount(0)
for (const name of ["Projects", "Worktrees", "Providers", "Models", "Extensions"]) {
await settings.getByRole("tab", { name, exact: true }).click()
await expect(settings.locator('[data-action="settings-server-select"]')).toHaveCount(0)
}
await settings.getByRole("tab", { name: "Server", exact: true }).click()
await expect(settings.getByRole("button", { name: "Add server", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
const connection = settings.locator('[data-component="settings-server-connection"]')
await expect(connection.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(connection.locator('[data-component="settings-list"]')).toHaveCSS("padding-left", "16px")
await expect(connection.locator(".settings-servers-row")).toHaveCSS("padding-top", "20px")
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "4px")
await expect(connection.locator(".settings-servers-copy")).toHaveCSS("row-gap", "6px")
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await expect(settings.getByText("Terminal shell", { exact: true })).toBeVisible()
await settings.getByText("zsh", { exact: true }).click()
const updated = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/experimental/config",
)
await page.getByRole("option", { name: "bash", exact: true }).click()
expect((await updated).postDataJSON()).toEqual({ shell: "/bin/bash" })
})
test("project settings open as a nested autosaving view", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: "Settings demo", exact: true }).click()
await expect(settings.getByRole("button", { name: "Back to projects", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Settings demo", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Worktrees", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Scripts", exact: true })).toHaveCount(0)
const name = settings.getByRole("textbox", { name: "Project name", exact: true })
const saved = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/project/proj_settings_demo",
)
await name.fill("Renamed project")
await name.blur()
expect((await saved).postDataJSON()).toEqual({ name: "Renamed project" })
await expect(settings.getByRole("tab", { name: "Renamed project", exact: true })).toBeVisible()
const startup = settings.getByRole("textbox", { name: "Worktree startup script", exact: true })
const scriptSaved = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/project/proj_settings_demo",
)
await startup.fill("bun install")
await startup.blur()
expect((await scriptSaved).postDataJSON()).toEqual({ commands: { start: "bun install" } })
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
})
test("clearing project fields sends explicit removal values", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: "Settings demo", exact: true }).click()
const startup = settings.getByRole("textbox", { name: "Worktree startup script", exact: true })
await expect(startup).toHaveValue("echo setup")
const scriptSaved = page.waitForResponse(
(response) =>
response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo",
)
await startup.clear()
await startup.blur()
const scriptResponse = await scriptSaved
expect(scriptResponse.ok()).toBe(true)
expect(scriptResponse.request().postDataJSON()).toEqual({ commands: { start: "" } })
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
const icon = settings.getByRole("button", { name: "Project icon", exact: true })
await expect(icon.locator("img")).toHaveCount(1)
const iconSaved = page.waitForResponse(
(response) =>
response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo",
)
await icon.hover()
await icon.click()
const iconResponse = await iconSaved
expect(iconResponse.ok()).toBe(true)
expect(iconResponse.request().postDataJSON()).toEqual({ icon: { color: "orange", override: "" } })
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
const color = settings.getByRole("button", { name: "Select orange color", exact: true })
await expect(color).toHaveAttribute("aria-pressed", "true")
const colorSaved = page.waitForResponse(
(response) =>
response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo",
)
await color.click()
const colorResponse = await colorSaved
expect(colorResponse.ok()).toBe(true)
expect(colorResponse.request().postDataJSON()).toEqual({ icon: { color: "", override: "" } })
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
})
test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeFocused()
@@ -38,7 +38,7 @@ for (const viewport of [
test("every settings page leaves room below its final content", async ({ page }) => {
await page.setViewportSize(viewport)
const settings = page.getByTestId("settings-screen")
const panel = settings.locator(":scope > .settings > .settings-panel:visible")
const panel = settings.locator(".settings-content > .settings-panel:visible")
if (viewport.bottom) {
const toggle = settings.locator('[data-action="settings-mobile-titlebar-bottom"]')
await toggle.locator('[data-slot="switch-control"]').click()
@@ -50,12 +50,12 @@ for (const viewport of [
"Appearance",
"Notifications",
"Shortcuts",
"Servers",
"Projects",
"Worktrees",
"Providers",
"Models",
"Extensions",
"Server",
"Experimental",
"About",
]) {
@@ -69,11 +69,8 @@ for (const colorScheme of ["light", "dark"] as const) {
await page.setViewportSize({ width: 1280, height: 720 })
await panel.getByText("rebase", { exact: true }).hover()
await panel.getByText("rebase", { exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox")).toHaveValue("rebase")
await expect(dialog.getByRole("textbox")).toBeFocused()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toBeHidden()
await expect(settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("rebase")
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
await page.setViewportSize({ width: 1280, height: 260 })
@@ -0,0 +1,471 @@
import { expect, test, type Page } from "@playwright/test"
import en from "../../src/runtime/i18n/en"
import { clientSettings } from "../../src/settings/search-catalog"
import { createMockServerHandler, mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const directory = "/projects/opencode"
const config = {
directory,
project: {
id: "proj_search",
canonical: directory,
name: "OpenCode",
icon: { color: "orange" },
vcs: "git",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
}
function projectList(count: number) {
return Array.from({ length: count }, (_, index) => ({
...config.project,
id: `proj_search_${index}`,
canonical: `${directory}-${index}`,
name: `OpenCode ${String(index).padStart(2, "0")}`,
}))
}
function ui(page: Page) {
const settings = page.getByTestId("settings-screen")
return {
settings,
search: settings.getByRole("combobox", { name: "Search", exact: true }),
results: settings.getByRole("listbox", { name: "Settings results", exact: true }),
viewport: settings.locator(".settings-search-scroll > .scroll-view__viewport"),
}
}
async function findShortcut(page: Page) {
// Browser emulation can report a different OS than the machine running Playwright.
const key = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Meta+f" : "Control+f"))
await page.keyboard.press(key)
}
test.use({ viewport: { width: 1280, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, config)
await page.route("https://api.github.com/**", (route) => route.fulfill({ json: [] }))
await page.goto("/")
if ((page.viewportSize()?.width ?? 1280) < 800) await page.getByRole("button", { name: "Tabs", exact: true }).click()
await page.getByRole("button", { name: "Settings", exact: true }).click()
const view = ui(page)
await expect(view.settings).toBeFocused()
// Readiness includes the server-backed project inventory, not just the settings shell.
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(1)
await view.search.clear()
})
test("pointer selection, keyboard navigation, and local find shortcut", async ({ page }) => {
const view = ui(page)
await view.search.fill("font")
const code = view.results.getByRole("option", { name: "Code Font, Appearance", exact: true })
const terminal = view.results.getByRole("option", { name: "Terminal Font, Appearance", exact: true })
const font = view.results.getByRole("option", { name: "UI Font, Appearance", exact: true })
await expect(view.results.getByRole("option")).toHaveText([
"Code FontAppearance",
"Terminal FontAppearance",
"UI FontAppearance",
])
await code.click()
await expect(code).toBeFocused()
await expect(view.settings.getByRole("textbox", { name: "Code Font", exact: true })).toBeInViewport()
await code.press("ArrowDown")
await expect(terminal).toBeFocused()
await expect(terminal).toHaveAttribute("aria-selected", "true")
await terminal.press("Enter")
await expect(terminal).toBeFocused()
await expect(view.settings.getByRole("textbox", { name: "Terminal Font", exact: true })).toBeInViewport()
await terminal.press("End")
await expect(font).toBeFocused()
await font.press("Home")
await expect(code).toBeFocused()
await terminal.focus()
await expect(terminal).toHaveAttribute("aria-selected", "true")
await terminal.press("Enter")
await expect(view.settings.locator('[data-search-target="row"]')).toContainText("Terminal Font")
await findShortcut(page)
await expect(view.search).toBeFocused()
expect(await view.search.evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd])).toEqual([
0, 4,
])
await view.settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(view.settings).toBeHidden()
await findShortcut(page)
await expect(page.getByRole("textbox", { name: /Search sessions/ })).toBeFocused()
})
test("page priority, compact icons, section context, and the minimal empty state", async ({ page }) => {
const view = ui(page)
await view.search.fill("work")
await expect(view.results.getByRole("option")).toHaveText(["Worktrees", "Default environmentPreferences / General"])
const pageResult = view.results.getByRole("option", { name: /^Worktrees,/ })
await expect(pageResult).toHaveCSS("height", "28px")
await expect(pageResult.locator("svg")).toHaveCount(1)
await expect(view.settings.getByText("App settings", { exact: true })).toHaveCount(0)
await view.search.fill("Agent")
await expect(view.results.getByRole("option", { name: "Agent, Desktop notifications", exact: true })).toHaveText(
"AgentDesktop notifications",
)
await expect(view.results.getByRole("option", { name: "Agent, Sound effects", exact: true })).toHaveText(
"AgentSound effects",
)
await view.search.fill("zzzzzzzzzz")
await expect(view.results.getByRole("option")).toHaveCount(0)
await expect(view.settings.getByRole("status")).toHaveText('No results for "zzzzzzzzzz"')
await expect(view.settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await view.search.press("Escape")
await expect(view.search).toHaveValue("")
await expect(view.settings.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
})
test("empty queries keep the closing quote beside the ellipsis while typing and resizing", async ({ page }) => {
const view = ui(page)
const query = "zzzz 🧑🏽‍💻 ".repeat(40)
await view.search.fill(query)
const status = view.settings.getByRole("status")
const quoted = status.locator("bdi")
await expect(status).toHaveAccessibleName(`No results for "${query}"`)
await expect(quoted).toHaveText(/^".+…"$/)
await expect(status).toHaveCSS("height", "28px")
await expect
.poll(() =>
quoted.evaluate((element) => element.getBoundingClientRect().width <= element.parentElement!.clientWidth),
)
.toBe(true)
const text = await quoted.textContent()
await page.setViewportSize({ width: 390, height: 844 })
await expect(quoted).toHaveText(/^".+…"$/)
await expect(quoted).not.toHaveText(text!)
await expect
.poll(() =>
quoted.evaluate((element) => element.getBoundingClientRect().width <= element.parentElement!.clientWidth),
)
.toBe(true)
expect(await view.settings.evaluate((root) => root.scrollWidth <= root.clientWidth)).toBe(true)
await view.search.fill("zzzzzzzzzz")
await expect(status).toHaveText('No results for "zzzzzzzzzz"')
await view.search.pressSequentially("x")
await expect(status).toHaveText('No results for "zzzzzzzzzzx"')
})
test("Models and Shortcuts autofocus their filters on normal navigation", async ({ page }) => {
const view = ui(page)
for (const entry of [
{ tab: "Models", search: "Search models" },
{ tab: "Shortcuts", search: "Search shortcuts" },
{ tab: "Models", search: "Search models" },
]) {
await view.settings.getByRole("tab", { name: entry.tab, exact: true }).click()
await expect(view.settings.getByRole("searchbox", { name: entry.search, exact: true })).toBeFocused()
}
await view.search.fill("shortcuts")
const result = view.results.getByRole("option", { name: "Keyboard shortcuts", exact: true })
await result.click()
await expect(result).toBeFocused()
})
for (const count of [7, 8]) {
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
await page.route("**/api/project", (route) => route.fulfill({ json: projectList(count) }))
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(count)
await view.search.clear()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
await expect(projects).toHaveCount(count)
if (count === 7) {
await expect(search).toHaveCount(0)
return
}
await expect(search).toBeFocused()
await search.fill(" CODE 06 ")
await expect(projects).toHaveCount(1)
await expect(projects).toHaveAccessibleName("OpenCode 06")
await expect(search).toBeVisible()
await search.fill("missing-project")
await expect(projects).toHaveCount(0)
await expect(view.settings.getByText("No projects found", { exact: true })).toBeVisible()
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
await expect(search).toBeFocused()
await expect(projects).toHaveCount(count)
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(search).toBeFocused()
await search.fill("OpenCode 06")
await projects.click()
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
})
}
test("Projects search focuses when the qualifying inventory arrives after opening", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
await page.route("**/api/project", async (route) => {
await inventory.promise
await route.fulfill({ json: projectList(8) })
})
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
await page.reload()
await requested
const view = ui(page)
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
try {
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
await expect(search).toHaveCount(0)
} finally {
inventory.resolve()
}
await expect(search).toBeFocused()
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
})
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
const view = ui(page)
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {
await view.search.fill(en[entry.label as keyof typeof en])
const result = view.results.locator(`[data-setting-target="${entry.target}"]`)
await expect(result).toHaveCount(1)
await result.click()
await expect(view.settings.locator(`[data-action="${entry.target}"]`)).toBeInViewport()
}
})
test("qualified project results preserve query and selection on return", async ({ page }) => {
const view = ui(page)
await view.search.fill("project name")
await expect(view.results.locator('[data-setting-target="settings-project-name"]')).toHaveCount(0)
await view.search.fill("OpenCode")
const project = view.results.getByRole("option")
await expect(project).toHaveText("OOpenCode")
await expect(project.locator('[data-component="project-avatar-v2"]')).toHaveCSS("width", "16px")
await expect(view.settings.locator(".settings-search-group")).toHaveCount(0)
await view.search.fill("OpenCode name")
const name = view.results.getByRole("option")
await expect(name).toHaveText("Project nameGeneral")
await name.click()
await expect(view.search).toHaveCount(0)
await expect(view.settings.getByRole("button", { name: "Back to settings", exact: true })).toBeVisible()
await expect(view.settings.getByRole("heading", { name: "OpenCode", exact: true })).toHaveCSS("line-height", "20px")
await expect(view.settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("OpenCode")
await page.keyboard.press("Escape")
await expect(view.search).toBeFocused()
await expect(view.search).toHaveValue("OpenCode name")
await expect(name).toHaveAttribute("aria-selected", "true")
})
test("returning from a project restores a scrolled result list", async ({ page }) => {
await page.route("**/api/project", (route) =>
route.fulfill({
json: projectList(30),
}),
)
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(30)
const scrollbar = view.settings.locator('.settings-search-scroll > .scroll-view__thumb[data-orientation="vertical"]')
await view.viewport.hover()
await expect(scrollbar).toHaveAttribute("data-visible", "true")
const bounds = (await scrollbar.boundingBox())!
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
await page.mouse.down()
await expect(scrollbar).toHaveAttribute("data-dragging", "true")
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 80, { steps: 4 })
await page.mouse.up()
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBeGreaterThan(0)
const project = view.results.getByRole("option", { name: /^OpenCode 25,/ })
await project.scrollIntoViewIfNeeded()
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBeGreaterThan(0)
const scroll = await view.viewport.evaluate((list) => list.scrollTop)
await project.click()
await expect(view.settings.getByRole("heading", { name: "OpenCode 25", exact: true })).toBeVisible()
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
await expect(view.search).toBeFocused()
await expect(project).toHaveAttribute("aria-selected", "true")
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBe(scroll)
await view.search.fill("about")
await expect(view.results.getByRole("option", { name: "About", exact: true })).toBeVisible()
await expect(scrollbar).toHaveCount(0)
})
test("IME confirmation does not activate a search result", async ({ page }) => {
const view = ui(page)
await view.search.fill("font")
await expect(view.results.getByRole("option")).toHaveCount(3)
await view.search.dispatchEvent("keydown", { key: "Enter", isComposing: true, bubbles: true, cancelable: true })
await expect(view.settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await view.search.press("Enter")
await expect(view.settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
})
test("header highlight runs once per search activation and finishes cleanly", async ({ page }) => {
const view = ui(page)
await view.settings.evaluate((root) => {
root.setAttribute("data-search-flashes", "0")
root.addEventListener("animationstart", (event) => {
if (!(event instanceof AnimationEvent) || event.animationName !== "settings-search-reveal") return
root.setAttribute("data-search-flashes", String(Number(root.getAttribute("data-search-flashes")) + 1))
})
})
await view.search.fill("skills")
await view.results.getByRole("option").click()
await expect(view.settings.getByRole("tab", { name: "Skills", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(view.settings).toHaveAttribute("data-search-flashes", "1")
await view.settings.evaluate(async (root) => {
await Promise.all(
root
.getAnimations({ subtree: true })
.filter(
(animation) => animation instanceof CSSAnimation && animation.animationName === "settings-search-reveal",
)
.map((animation) => animation.finished),
)
})
await expect(view.settings.locator("[data-search-target]")).toHaveCount(0)
for (const tab of ["MCPs", "Plugins", "Skills"]) {
await view.settings.getByRole("tab", { name: tab, exact: true }).click()
await expect(view.settings.getByRole("tab", { name: tab, exact: true })).toHaveAttribute("aria-selected", "true")
await expect(view.settings.locator("[data-search-target]")).toHaveCount(0)
}
await expect(view.settings).toHaveAttribute("data-search-flashes", "1")
await view.results.getByRole("option").click()
await expect(view.settings).toHaveAttribute("data-search-flashes", "2")
await view.search.fill("about")
await view.results.getByRole("option", { name: "About", exact: true }).click()
await expect(view.settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
await expect(view.settings).toHaveAttribute("data-search-flashes", "3")
})
test("multi-server results navigate to the named server and hide search in nested views", async ({ page }) => {
const server = "http://127.0.0.1:4097"
const remote = createMockServerHandler({
...config,
directory: "/remote/opencode",
project: { ...config.project, canonical: "/remote/opencode" },
})
page.on("close", () => void remote.dispose())
await installSseTransport(page, { server })
await page.route(`${server}/api/**`, async (route) => {
if (route.request().method() === "OPTIONS")
return route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "*" },
})
const response = await remote.handler(
new Request(route.request().url(), { method: route.request().method(), headers: route.request().headers() }),
)
await route.fulfill({
status: response.status,
headers: { ...Object.fromEntries(response.headers), "access-control-allow-origin": "*" },
body: Buffer.from(await response.arrayBuffer()),
})
})
await page.addInitScript(
(server) =>
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", displayName: "Build server", http: { url: server } }] }),
),
server,
)
await page.reload()
const view = ui(page)
await view.search.fill("MCPs")
await expect(view.results.getByRole("option")).toHaveCount(2)
await view.results.getByRole("option", { name: "MCPs, Build server, Extensions", exact: true }).click()
await expect(view.search).toHaveCount(0)
await expect(view.settings.getByRole("tab", { name: "Build server", exact: true })).toBeVisible()
await expect(view.settings.getByRole("tab", { name: "MCPs", exact: true })).toHaveAttribute("aria-selected", "true")
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
await expect(view.search).toHaveValue("MCPs")
await view.search.fill("Build server models")
await view.results.getByRole("option").click()
await expect(view.settings.getByRole("searchbox", { name: "Search models", exact: true })).toBeFocused()
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
await view.search.fill("Build server OpenCode name")
await expect(view.results.getByRole("option")).toHaveCount(1)
await view.results.getByRole("option").click()
await expect(view.settings.getByRole("button", { name: "Build server", exact: true })).toBeVisible()
await expect(view.settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("OpenCode")
})
for (const direction of ["ltr", "rtl"] as const) {
test.describe(`search layout ${direction}`, () => {
test.use({
viewport: { width: 390, height: 844 },
colorScheme: direction === "rtl" ? "dark" : "light",
contextOptions: { reducedMotion: "reduce" },
})
test("search input fades track typing, caret scrolling, resizing, and clearing", async ({ page }) => {
const view = ui(page)
await page.setViewportSize({ width: 1280, height: 900 })
await page.evaluate((direction) => {
document.documentElement.dir = direction
}, direction)
await view.search.fill(direction === "rtl" ? "غيرموجود ".repeat(30) : "zzzz ".repeat(30))
await view.search.press("End")
await expect(view.search).toHaveAttribute("data-overflow-start", "true")
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
await expect(view.search).not.toHaveCSS("mask-image", "none")
await view.search.press("Home")
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
await expect(view.search).toHaveAttribute("data-overflow-end", "true")
await view.search.evaluate((input: HTMLInputElement, direction) => {
input.scrollLeft = ((input.scrollWidth - input.clientWidth) / 2) * (direction === "rtl" ? -1 : 1)
}, direction)
await expect(view.search).toHaveAttribute("data-overflow-start", "true")
await expect(view.search).toHaveAttribute("data-overflow-end", "true")
await view.search.fill("z".repeat(50))
await expect(view.search).not.toHaveCSS("mask-image", "none")
await page.setViewportSize({ width: 600, height: 844 })
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
await expect(view.search).toHaveCSS("mask-image", "none")
await view.search.fill("zzzz ".repeat(30))
await expect(view.search).not.toHaveCSS("mask-image", "none")
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
await expect(view.search).toHaveValue("")
await expect(view.search).toBeFocused()
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
await expect(view.search).toHaveCSS("mask-image", "none")
})
test("keeps input/content stable and hides the active descendant when results collapse", async ({ page }) => {
const view = ui(page)
await page.evaluate((direction) => {
document.documentElement.dir = direction
}, direction)
const input = await view.search.boundingBox()
const content = await view.settings.locator(".settings-content").boundingBox()
await view.search.fill("e")
await expect.poll(() => view.search.boundingBox()).toEqual(input)
await expect.poll(() => view.settings.locator(".settings-content").boundingBox()).toEqual(content)
await view.search.fill("terminal font")
await view.results.getByRole("option").click()
await expect(view.results).toBeHidden()
await expect(view.search).toHaveAttribute("aria-expanded", "false")
await expect(view.search).not.toHaveAttribute("aria-activedescendant", /.+/)
await expect(view.settings.getByRole("textbox", { name: "Terminal Font", exact: true })).toBeInViewport()
await findShortcut(page)
await expect(view.search).toBeFocused()
await expect(view.results).toBeVisible()
await expect(view.search).toHaveAttribute("aria-activedescendant", /.+/)
expect(await view.settings.evaluate((root) => root.scrollWidth <= root.clientWidth)).toBe(true)
})
})
}
@@ -0,0 +1,48 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ viewport: { width: 1280, height: 900 } })
for (const mode of ["failed", "stopped", "ready"] as const) {
test(`manages a ${mode} configured WSL server from nested settings`, async ({ page }) => {
await mockOpenCodeServer(page, {
directory: "/repo",
project: {
id: "proj_wsl_settings",
canonical: "/repo",
name: "WSL project",
sandboxes: [],
time: { created: 1, updated: 1 },
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server, mode })}`)
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Local Server", exact: true })).toBeEnabled()
const ubuntu = settings.getByRole("tab", { name: "Ubuntu", exact: true })
await expect(ubuntu).toHaveCount(1)
await ubuntu.click()
await expect(settings.getByRole("heading", { name: "Ubuntu", exact: true })).toBeVisible()
const connection = settings.locator('[data-component="settings-server-connection"]')
if (mode !== "ready") {
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeDisabled()
await connection.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Retry start", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toHaveText("start:wsl:Ubuntu")
}
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeEnabled()
await connection.getByRole("button", { name: "Update OpenCode", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toContainText("update:Ubuntu")
await expect(connection.getByRole("button", { name: "Update OpenCode", exact: true })).toHaveCount(0)
await connection.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Remove", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toContainText("remove:wsl:Ubuntu")
await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(0)
await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeEnabled()
})
}
@@ -188,16 +188,8 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true }),
).toBeVisible()
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
const status = sidebar.getByRole("button", { name: "Status", exact: true })
await expect(status).toBeVisible()
await expect
.poll(async () => {
const bounds = await sidebar.boundingBox()
const button = await status.boundingBox()
return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12
})
.toBe(true)
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
await expect(sidebar.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
await expect
.poll(async () => {
@@ -301,7 +293,7 @@ for (const count of [0, 26]) {
}
for (const direction of ["ltr", "rtl"]) {
test(`vertical tabs keep Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
test(`vertical tabs scroll without the retired Status footer in ${direction}`, async ({ page }, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, directory }) => {
@@ -334,38 +326,23 @@ for (const direction of ["ltr", "rtl"]) {
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
await expect(status).toHaveText("Status")
await expect(status).toHaveCount(0)
await expect(settings).toHaveCount(0)
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 360 })
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCSS("margin-top", "8px")
await expect(status).toBeInViewport({ ratio: 1 })
await expect(status).toHaveCSS("height", "28px")
await expect
.poll(() =>
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
const content = Math.max(
0,
...Array.from(element.children, (child) => child.getBoundingClientRect().height),
)
return element.getBoundingClientRect().height - content
}),
)
.toBe(0)
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
await scroll.evaluate((element) => element.scrollTo(0, 0))
await expect(scroll).toHaveJSProperty("scrollTop", 0)
const pinnedStatus = await status.boundingBox()
await scroll.hover()
await page.mouse.wheel(0, 200)
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await testInfo.attach(`vertical-tabs-status-${width}`, {
await expect(status).toHaveCount(0)
await testInfo.attach(`vertical-tabs-scroll-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
@@ -379,14 +356,9 @@ for (const direction of ["ltr", "rtl"]) {
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
})
.toBe(true)
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await expect(status).toHaveCount(0)
await expect(settings).toHaveCount(0)
}
await status.click()
await expect(status).toHaveAttribute("aria-expanded", "true")
await status.press("Escape")
await expect(status).toHaveAttribute("aria-expanded", "false")
})
}
@@ -501,7 +473,7 @@ test("dedicated experimental settings control vertical tab details", async ({ pa
await settings.locator('[data-action="settings-show-project-name"] [data-slot="switch-control"]').click()
await expect(projectNameSwitch).toBeChecked()
await expect(projectNames).toHaveText(["tab-project"])
await expect(settings.getByRole("tablist")).toHaveCSS("width", "240px")
await expect(settings.getByRole("complementary")).toHaveCSS("width", "240px")
await page.setViewportSize({ width: 920, height: 720 })
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
@@ -620,7 +592,7 @@ async function mockServer(page: Page) {
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory: sessionA.directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
if (url.pathname === "/api/project") {
const project = {
id: sessionA.projectID,
canonical: sessionA.directory,
@@ -628,10 +600,7 @@ async function mockServer(page: Page) {
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(
route,
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
)
return json(route, [project])
}
if (url.pathname === "/api/location")
return json(route, {
@@ -0,0 +1,219 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/workspaces-prefetch"
const sandboxes = [`${directory}/first`, `${directory}/second`]
const project = {
id: "proj_workspaces_prefetch",
canonical: directory,
name: "Prefetch project",
sandboxes,
time: { created: 1, updated: 1 },
}
const other = { ...project, id: "proj_other", name: "Other project", canonical: "/repo/other", sandboxes: [] }
test.use({ viewport: { width: 1280, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project,
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: "ses_workspaces_cached",
title: "Cached worktree session",
projectID: project.id,
directory: sandboxes[0],
time: { created: 1, updated: 1 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
await expect(page.getByText("Cached worktree session", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
})
for (const interaction of ["hover", "focus"] as const) {
test(`project Worktrees ${interaction} prefetches only its inventory and reuses the request`, async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const calls: string[] = []
const sessions: string[] = []
await page.route(
(url) => url.pathname === "/api/project",
(route) => route.fulfill({ json: [project, other] }),
)
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
calls.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
await inventory.promise
await route.fallback()
},
)
page.on("request", (request) => {
const url = new URL(request.url())
if (url.pathname === "/api/session" && url.searchParams.has("directory"))
sessions.push(url.searchParams.get("directory")!)
})
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: project.name, exact: true }).click()
const worktrees = settings.getByRole("tab", { name: "Worktrees", exact: true })
await expect(worktrees).toBeEnabled()
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
await worktrees[interaction]()
await requested
await expect(worktrees).toHaveAttribute("aria-selected", "false")
await expect.poll(() => calls).toEqual([directory])
expect(sessions).toEqual([])
if (interaction === "hover") {
const finished = page.waitForEvent(
"requestfinished",
(request) => new URL(request.url()).pathname === "/api/worktree",
)
inventory.resolve()
await finished
}
await worktrees.click()
await expect(worktrees).toHaveAttribute("aria-selected", "true")
inventory.resolve()
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible()
expect(calls).toEqual([directory])
await expect.poll(() => sessions.toSorted()).toEqual(sandboxes.toSorted())
})
}
for (const nested of [false, true]) {
test(`${nested ? "nested" : "root"} server Worktrees hover only prefetches metadata`, async ({ page }) => {
if (nested) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript((server) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: [
{ type: "http", displayName: "Settings server", http: { url: server } },
{ type: "http", displayName: "Other server", http: { url: "http://127.0.0.1:4097" } },
],
}),
)
}, server)
await page.reload()
await page.getByTestId("settings-screen").getByRole("tab", { name: "Settings server", exact: true }).click()
}
const calls = { projects: 0, worktrees: [] as string[] }
await page.route(
(url) => url.pathname === "/api/project",
async (route) => {
calls.projects += 1
await route.fulfill({ json: [project, other] })
},
)
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
const requested = new URL(route.request().url()).searchParams.get("location[directory]") ?? ""
calls.worktrees.push(requested)
if (requested === other.canonical) return route.fulfill({ json: [{ directory: other.canonical }] })
await route.fallback()
},
)
const settings = page.getByTestId("settings-screen")
const worktrees = settings.getByRole("tab", { name: "Worktrees", exact: true })
await expect(worktrees).toBeEnabled()
const fetched = page.waitForEvent(
"requestfinished",
(request) => new URL(request.url()).pathname === "/api/project",
)
await worktrees.hover()
await fetched
await worktrees.focus()
await expect(worktrees).toHaveAttribute("aria-selected", "false")
expect(calls).toEqual({ projects: 1, worktrees: [] })
await worktrees.click()
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
expect(calls.projects).toBe(1)
expect(calls.worktrees.toSorted()).toEqual([directory, other.canonical].toSorted())
})
}
test("cached sessions render while directory sessions load without treating unknown rows as empty", async ({
page,
}) => {
const ready = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/session" && url.searchParams.has("directory"),
async (route) => {
await ready.promise
await route.fallback()
},
)
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/session" && url.searchParams.has("directory")
})
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await requested
await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible()
const empty = settings
.locator(".settings-workspaces-row")
.filter({ has: page.getByLabel(sandboxes[1], { exact: true }) })
await expect(empty).toContainText("Loading messages")
await settings.getByRole("button", { name: "More options", exact: true }).click()
await expect(page.getByRole("menuitem", { name: "Delete worktrees without sessions", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
ready.resolve()
await expect(empty).toContainText("0 sessions")
await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible()
})
test("project deletion updates the cached server-wide inventory", async ({ page }) => {
const removed = new Set<string>()
await page.route(
(url) => url.pathname === "/api/worktree",
(route) => {
if (route.request().method() === "DELETE") {
removed.add(route.request().postDataJSON().directory)
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: [
{ directory },
...sandboxes
.filter((directory) => !removed.has(directory))
.map((directory) => ({ directory, strategy: "git" })),
],
})
},
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: project.name, exact: true }).click()
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await settings.getByRole("button", { name: "Delete “second”?", exact: true }).click()
await page
.getByRole("dialog", { name: "Delete “second”?", exact: true })
.getByRole("button", { name: "Delete worktree", exact: true })
.click()
await expect(settings.getByText("1 worktree", { exact: true })).toBeVisible()
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText("1 worktree", { exact: true })).toBeVisible()
await expect(settings.getByLabel(sandboxes[1], { exact: true })).toHaveCount(0)
})
@@ -75,13 +75,16 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
const blocked: ServerResponse[] = []
const release = () => blocked.splice(0).forEach((response) => response.end(builds.new["/large.bin"]))
const server = createServer((request, response) => {
const path = new URL(request.url ?? "/", "http://localhost").pathname
const url = new URL(request.url ?? "/", "http://localhost")
const path = url.pathname
requests.push(path)
response.setHeader("cache-control", "no-store")
if (path === "/observer.html")
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Worker observer</title>")
if (path === "/api/health")
return void response.writeHead(200, { "content-type": "application/json" }).end('{"healthy":true}')
if (path === "/api/status")
return void response
.writeHead(200, { "content-type": "application/json" })
.end(`{"version":"test","pid":1,"urls":["${url.origin}"]}`)
if (path === "/sw.js" && state.legacy && state.version === "old") {
// Model the shipped worker's shared precache name and cache-first navigation behavior.
const urls = Object.keys(builds.old).filter(
@@ -331,8 +334,8 @@ fixture("upgrades the legacy shared precache only after old tabs close", async (
fixture("does not substitute cached HTML for API or missing asset navigations", async ({ page, site }) => {
await install(page, site.url)
const api = await page.goto(`${site.url}/api/health`)
expect(await api?.json()).toEqual({ healthy: true })
const api = await page.goto(`${site.url}/api/status`)
expect(await api?.json()).toEqual({ version: "test", pid: 1, urls: [site.url] })
expect(api?.fromServiceWorker()).toBe(false)
const asset = await page.goto(`${site.url}/_assets/missing.js`)
expect(asset?.status()).toBe(404)
@@ -0,0 +1,39 @@
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "../../src/app"
import { useLanguage, type Direction } from "../../src/runtime/i18n/language"
import { PlatformProvider } from "../../src/runtime/platform/platform"
import { createWebPlatform } from "../../src/runtime/platform/web"
import { ServerConnection } from "../../src/runtime/server/registry"
export function mount(input: { server: string; route: string; direction: Direction }) {
const root = document.getElementById("root")
if (!root) throw new Error("Missing fixture root")
const history = createMemoryHistory()
history.set({ value: input.route, replace: true, scroll: false })
const server: ServerConnection.Http = { type: "http", http: { url: input.server } }
function DirectedApp() {
const language = useLanguage()
language.setDirection(input.direction)
return (
<AppInterface
servers={[server]}
defaultServer={ServerConnection.key(server)}
canonicalLocalServer={ServerConnection.key(server)}
router={(props) => <MemoryRouter {...props} history={history} />}
/>
)
}
render(
() => (
<PlatformProvider value={createWebPlatform("test").platform}>
<AppBaseProviders locale="en">
<DirectedApp />
</AppBaseProviders>
</PlatformProvider>
),
root,
)
}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
/>
</head>
<body
class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep"
>
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
<script type="module">
import { mount } from "./app-direction.fixture.tsx"
const query = new URLSearchParams(location.search)
mount({ server: query.get("server"), route: query.get("route"), direction: query.get("direction") })
</script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import type { Page } from "@playwright/test"
export async function openWithDirection(page: Page, route: string, direction: "ltr" | "rtl") {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.goto(`/e2e/utils/app-direction.html?${new URLSearchParams({ server, route, direction })}`)
}
+16 -2
View File
@@ -32,7 +32,7 @@ export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBa
}) {}
const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("health", "/api/health", { success: Json }))
.add(HttpApiEndpoint.get("status", "/api/status", { success: Json }))
.add(
HttpApiEndpoint.get("event", "/api/event", {
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
@@ -70,7 +70,21 @@ const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("mcp", "/api/mcp", { success: Json }))
.add(HttpApiEndpoint.get("mcpResource", "/api/mcp/resource", { success: Json }))
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
.add(
HttpApiEndpoint.patch("projectUpdate", "/api/project/:projectID", {
params: { projectID: Schema.String },
payload: JsonPayload,
success: Json,
}),
)
.add(HttpApiEndpoint.get("configShells", "/api/config/shell", { success: Json }))
.add(
HttpApiEndpoint.patch("configUpdate", "/api/experimental/config", {
payload: Schema.Struct({ shell: Schema.NullOr(Schema.String) }),
success: HttpApiSchema.NoContent,
}),
)
.add(HttpApiEndpoint.get("websearchProviders", "/api/websearch/provider", { success: Json }))
.add(
HttpApiEndpoint.get("worktreeList", "/api/worktree", {
success: Json,
+23 -15
View File
@@ -6,9 +6,13 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { MockApi, MockBadRequest, MockNotFound } from "./mock-api"
export interface MockServerConfig {
server?: string
provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]>
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
shells?: unknown[]
configEntries?: unknown[]
websearchProviders?: unknown[]
directory: string
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
@@ -47,7 +51,9 @@ type MockStreamWindow = Window & {
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const server =
config.server ??
`http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ server, retry }) => {
@@ -185,13 +191,14 @@ export function createMockServerHandler(config: MockServerConfig) {
const corsHeaders = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"access-control-expose-headers": "x-next-cursor",
}
function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, string>; nextCursor: number }) {
const noContent = Effect.succeed(HttpApiSchema.NoContent.make())
const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay))
const configEntries = config.configEntries ?? []
return HttpApiBuilder.group(MockApi, "mock", (handlers) =>
handlers
.handleRaw("event", () => {
@@ -212,8 +219,8 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
}),
)
.handleAll({
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
config: () => Effect.succeed([]),
status: () => Effect.succeed({ version: "2.0.0", pid: 1, urls: config.server ? [config.server] : [] }),
config: () => Effect.succeed(configEntries),
reference: () =>
Effect.succeed({
location: {
@@ -269,12 +276,18 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return Effect.succeed([{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }])
},
projectCurrent: () =>
Effect.succeed({
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
}),
projectUpdate: (ctx) => {
const project = config.project as { canonical?: string }
return Effect.succeed({
...project,
...ctx.payload,
id: ctx.params.projectID,
canonical: project.canonical ?? config.directory,
})
},
configShells: () => Effect.succeed(config.shells ?? []),
configUpdate: () => noContent,
websearchProviders: () => Effect.succeed({ location: location(config), data: config.websearchProviders ?? [] }),
worktreeList: () =>
Effect.succeed([
{ directory: config.directory },
@@ -634,11 +647,6 @@ export function currentSession(session: { id: string } & Record<string, unknown>
: typeof session.directory === "string"
? session.directory
: fallbackDirectory,
...(typeof session.workspaceID === "string"
? { workspaceID: session.workspaceID }
: "workspaceID" in location && typeof location.workspaceID === "string"
? { workspaceID: location.workspaceID }
: {}),
},
subpath: session.subpath ?? session.path,
revert: session.revert,
@@ -0,0 +1,135 @@
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
import { createMemo, Show } from "solid-js"
import { createStore, unwrap } from "solid-js/store"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "../../src/app"
import { PlatformProvider, type Platform } from "../../src/runtime/platform/platform"
import { ServerConnection } from "../../src/runtime/server/registry"
import { useWslServers } from "../../src/servers/wsl/context"
import type { WslServersEvent, WslServersPlatform, WslServersState } from "../../src/servers/wsl/types"
export function mount(input: { server: string; mode: "failed" | "stopped" | "ready" }) {
const root = document.getElementById("root")
if (!root) throw new Error("Missing fixture root")
const history = createMemoryHistory()
history.set({ value: "/settings", replace: true, scroll: false })
render(() => {
const [store, setStore] = createStore<{ calls: string[]; state: WslServersState }>({
calls: [],
state: {
runtime: { available: true, version: "2", error: null },
installed: [],
online: [],
distroProbes: {},
pendingRestart: false,
job: null,
servers: [
{
config: { id: "wsl:Ubuntu", distro: "Ubuntu" },
runtime:
input.mode === "ready"
? { kind: "ready", url: input.server, password: null }
: input.mode === "failed"
? { kind: "failed", message: "WSL failed to start" }
: { kind: "stopped" },
},
],
opencodeChecks: {
Ubuntu: {
distro: "Ubuntu",
resolvedPath: "/usr/bin/opencode",
version: "old",
expectedVersion: "current",
matchesDesktop: false,
error: null,
},
},
},
})
const listeners = new Set<(event: WslServersEvent) => void>()
const publish = () =>
listeners.forEach((listener) => listener({ type: "state", state: structuredClone(unwrap(store.state)) }))
const unused = async () => {
throw new Error("Unexpected fixture action")
}
const wsl: WslServersPlatform = {
getState: async () => structuredClone(unwrap(store.state)),
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
probeRuntime: unused,
refreshDistros: unused,
installWsl: unused,
installDistro: unused,
probeAddable: unused,
openTerminal: unused,
addServer: unused,
async installOpencode(distro) {
setStore("calls", (calls) => [...calls, `update:${distro}`])
setStore("state", "opencodeChecks", distro, { version: "current", matchesDesktop: true })
publish()
},
async startServer(id) {
setStore("calls", (calls) => [...calls, `start:${id}`])
setStore("state", "servers", (server) => server.config.id === id, "runtime", {
kind: "ready",
url: input.server,
password: null,
})
publish()
},
async removeServer(id) {
setStore("calls", (calls) => [...calls, `remove:${id}`])
setStore("state", "servers", (servers) => servers.filter((server) => server.config.id !== id))
publish()
},
}
const platform: Platform = {
platform: "desktop",
os: "windows",
windowID: "settings-wsl-test",
openExternal: () => undefined,
openDirectoryPickerDialog: async () => null,
notify: async () => undefined,
restart: unused,
wslServers: wsl,
}
function Interface() {
const wsl = useWslServers()
const servers = createMemo<ServerConnection.Any[]>(() => [
{ type: "sidecar", variant: "base", displayName: "Local Server", http: { url: input.server } },
...(wsl.data?.servers ?? []).flatMap((item): ServerConnection.Any[] =>
item.runtime.kind === "ready"
? [
{
type: "sidecar",
variant: "wsl",
distro: item.config.distro,
displayName: item.config.distro,
http: { url: item.runtime.url },
},
]
: [],
),
])
return (
<Show when={wsl.data}>
<AppInterface
servers={servers()}
defaultServer={ServerConnection.Key.make("sidecar")}
router={(props) => <MemoryRouter {...props} history={history} />}
/>
</Show>
)
}
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale="en">
<output aria-label="WSL actions">{store.calls.join(",")}</output>
<Interface />
</AppBaseProviders>
</PlatformProvider>
)
}, root)
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root"></div>
<script type="module">
import { mount } from "./settings-wsl.fixture.tsx"
const query = new URLSearchParams(location.search)
mount({ server: query.get("server"), mode: query.get("mode") })
</script>
</body>
</html>
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.0",
"version": "2.0.3",
"description": "",
"type": "module",
"exports": {
@@ -84,6 +84,7 @@
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"core-js": "3.50.0",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
+1
View File
@@ -40,6 +40,7 @@ export default defineConfig({
reuseExistingServer: !built,
timeout: 120_000,
env: {
VITE_OPENCODE_TEST_FIXTURES: "1",
VITE_OPENCODE_SERVER_HOST: serverHost,
VITE_OPENCODE_SERVER_PORT: serverPort,
},
@@ -99,7 +99,7 @@ describe("Composer store", () => {
expect(prompt.state.prompt).toEqual([{ type: "text", content: "old", start: 0, end: 3 }])
})
test("prepends a slash skill to an attachment-only draft without flattening it", () => {
test("prepends a skill mention to an attachment-only draft without flattening it", () => {
const prompt = createPromptStore()
prompt.setPrompt([{ type: "file", path: "one", content: "@one", start: 0, end: 4 }], 4)
prompt.addMention(
@@ -107,14 +107,14 @@ describe("Composer store", () => {
type: "skill",
id: Skill.ID.make("show-me"),
name: Skill.Name.make("Show Me"),
content: "/show-me",
content: "@show-me",
start: 0,
end: 0,
},
{ start: 0, end: 0 },
)
expect(prompt.state.prompt).toMatchObject([
{ type: "skill", id: "show-me", content: "/show-me", start: 0, end: 8 },
{ type: "skill", id: "show-me", content: "@show-me", start: 0, end: 8 },
{ type: "text", content: " ", start: 8, end: 9 },
{ type: "file", path: "one", content: "@one", start: 9, end: 13 },
])
+1 -22
View File
@@ -21,7 +21,7 @@ import type { ImageAttachmentPart } from "./state"
import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit, withSlashSkill } from "./submit"
import { createComposerSubmit } from "./submit"
export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
@@ -243,9 +243,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
type: "builtin" as const,
})),
])
const slashSkills = createMemo(() =>
skills().filter((skill) => skill.slash === true && !slashCommands().some((item) => item.trigger === skill.id)),
)
const commands = createMemo<ComposerSuggestion[]>(() => [
...slashCommands().map((item) => ({
id: item.id,
@@ -256,29 +253,12 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
description: item.description,
keybind: command.keybindParts(item.id),
})),
...slashSkills().map((skill) => ({
id: `skill:${skill.id}`,
kind: "skill" as const,
label: `/${skill.id}`,
trigger: skill.id,
title: skill.name,
description: skill.description,
mention: {
type: "skill" as const,
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
content: `/${skill.id}`,
start: 0,
end: 0,
},
})),
])
const variants = createMemo(() => ["default", ...adapter.controls().model.selection.variant.list()])
const submission = createComposerSubmit({
adapter,
mode,
commands: () => data.location.command.list({ directory: sdk().directory }),
skills: slashSkills,
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
addToHistory: (value, mode) => controller.addHistory(value, mode),
@@ -407,7 +387,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
// the composer value as a new prompt. Enter keeps it queued in
// place; the alternate action sends it as a steer.
if (queue?.editing()) {
prompt.set(withSlashSkill(prompt.current(), slashSkills()))
queue.confirmEdit(submitOptions?.alternate ? "steer" : "queue")
return
}
-116
View File
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import type { ModelSelection } from "@/providers/models/selection"
import type { SessionMessageUser } from "@opencode/client/promise"
import { Skill } from "@opencode/schema/skill"
import { AbsolutePath } from "@opencode/schema/schema"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import { createMemoryComposerState } from "./state"
import { createComposerSubmit } from "./submit"
@@ -32,16 +31,6 @@ const selection = {
},
} satisfies ModelSelection
const slashSkill = Skill.Info.make({
id: Skill.ID.make("show-me"),
name: Skill.Name.make("Show Me"),
description: "Explain visually",
slash: true,
autoinvoke: false,
location: AbsolutePath.make("/skills/show-me/SKILL.md"),
content: "Explain visually",
})
function controls(): ComposerControls {
return {
agents: {
@@ -64,13 +53,11 @@ function submitInput(
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
skills: () => readonly Skill.Info[] | undefined = () => [],
) {
return createComposerSubmit({
adapter,
mode: () => mode,
commands,
skills,
editor: () => undefined,
queueScroll() {},
addToHistory() {},
@@ -268,107 +255,6 @@ describe("Composer submission", () => {
expect(state.current()[0]).toMatchObject({ content: "/review changes" })
})
test("submits a slash skill with its trailing text and attachments", async () => {
const state = createMemoryComposerState({ prompt: "/show-me explain " }).capture()
state.set([
{ type: "text", content: "/show-me explain ", start: 0, end: 17 },
{ type: "file", path: "src/cache.ts", content: "@src/cache.ts", start: 17, end: 30 },
])
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
undefined,
"normal",
() => [],
() => [slashSkill],
).submit(new Event("submit"))
const request = await admitted.promise
expect(request.text).toBe("/show-me explain @src/cache.ts")
expect(request.skills).toEqual([
expect.objectContaining({ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }),
])
expect(request.files).toEqual([
{ uri: "file:///C:/repo/src/cache.ts", name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } },
])
})
test.each([
{ text: "/show-me", slash: true, expected: ["show-me"] },
{ text: "/show-me\nexplain caching", slash: true, expected: ["show-me"] },
{ text: "/show-me\texplain caching", slash: true, expected: ["show-me"] },
{ text: "/show-me", slash: false, expected: [] },
{ text: "/show-me", slash: undefined, expected: [] },
{ text: "/show-me-extra", slash: true, expected: [] },
{ text: "Explain /show-me", slash: true, expected: [] },
])("resolves raw slash skill input $text with slash=$slash", async ({ text, slash, expected }) => {
const state = createMemoryComposerState({ prompt: text }).capture()
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
undefined,
"normal",
() => [],
() => [{ ...slashSkill, slash }],
).submit(new Event("submit"))
const request = await admitted.promise
expect(request.text).toBe(text)
expect(request.skills?.map((skill) => skill.id)).toEqual([...expected])
})
test("captures slash skills before creating a session in a new worktree", async () => {
const state = createMemoryComposerState({ prompt: "/show-me" }).capture()
let skills: readonly Skill.Info[] | undefined = [slashSkill]
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state,
ready: () => true,
controls,
working: () => false,
submitted() {},
async start() {
skills = undefined
return { session: target, cleanupReady: Promise.resolve() }
},
}
await submitInput(
adapter,
undefined,
"normal",
() => [],
() => skills,
).submit(new Event("submit"))
expect((await admitted.promise).skills).toEqual([
expect.objectContaining({ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }),
])
})
test("sends one captured value with explicit delivery after selection switches", async () => {
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
const calls: string[] = []
@@ -652,7 +538,6 @@ describe("Composer submission", () => {
notify,
"normal",
() => [],
() => [slashSkill],
)
await submission.submit(new Event("submit"))
@@ -744,7 +629,6 @@ describe("Composer submission", () => {
undefined,
"normal",
() => catalog,
() => [{ ...slashSkill, id: Skill.ID.make("review") }],
).submit(new Event("submit"))
expect(await sent.promise).toBe("command")
+1 -24
View File
@@ -1,6 +1,5 @@
import { SessionMessage } from "@opencode/schema/session-message"
import type { SessionMessageUser, SkillInfo } from "@opencode/client/promise"
import { Skill } from "@opencode/schema/skill"
import type { SessionMessageUser } from "@opencode/client/promise"
import { Event } from "@opencode/schema/event"
import type { Accessor } from "solid-js"
import type { PromptHistoryComment } from "./history/entry"
@@ -30,7 +29,6 @@ type ComposerSubmitInput = {
adapter: ComposerAdapter
mode: Accessor<"normal" | "shell">
commands: Accessor<readonly { name: string }[] | undefined>
skills: Accessor<readonly Pick<SkillInfo, "id" | "name" | "slash">[] | undefined>
editor: () => HTMLDivElement | undefined
queueScroll: () => void
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
@@ -71,7 +69,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const comments = input.comments.capture()
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
if (value.mode === "normal" && !command) value.prompt = withSlashSkill(value.prompt, input.skills())
try {
const started =
@@ -292,26 +289,6 @@ function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text
return { command, arguments: arguments_.join(" ") }
}
export function withSlashSkill(prompt: Prompt, skills: ReturnType<ComposerSubmitInput["skills"]>): Prompt {
const first = prompt[0]
if (first?.type !== "text") return prompt
const name = /^\/(\S+)(?:\s|$)/.exec(first.content)?.[1]
const skill = skills?.find((item) => item.slash === true && item.id === name)
if (!skill || prompt.some((part) => part.type === "skill" && part.id === skill.id)) return prompt
const content = `/${skill.id}`
return [
{
type: "skill",
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
content,
start: 0,
end: content.length,
},
{ ...first, content: first.content.slice(content.length), start: content.length },
...prompt.slice(1),
]
}
async function sendCommand(
session: ComposerSession,
+1
View File
@@ -1,5 +1,6 @@
// @refresh reload
import "@/runtime/polyfills"
import { init } from "@sentry/solid"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
+1 -1
View File
@@ -110,7 +110,7 @@ export function createHomeController() {
.list({ path: ".", location })
.then(async (files) => {
// TODO: Initialize empty directories when V2 exposes a native Git init API.
return ctx.sdk.api.project.current({ location })
return ctx.sdk.api.location.get({ location }).then((result) => result.project)
})
.then((project) => ctx.sync.child(item, { bootstrap: false })[1]("project", project.id))
.catch(() => undefined)
@@ -1,6 +1,7 @@
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { useServerActionsController } from "@/servers/registry/controller"
import { useSettingsCommand } from "@/settings/command"
import { useSettingsSurface } from "@/settings/surface"
import { type LocalProject } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
@@ -27,6 +28,7 @@ export function createHomeProjectsController(home: HomeController) {
const dialog = useDialog()
const language = useLanguage()
const openSettings = useSettingsCommand()
const settings = useSettingsSurface()
const serverManagement = useServerActionsController()
const global = useGlobal()
const authenticate = useSshAuthenticate()
@@ -131,8 +133,9 @@ export function createHomeProjectsController(home: HomeController) {
})
},
edit: (conn: ServerConnection.Any, project: LocalProject) => {
void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => {
void dialog.show(() => <DialogEditProject server={conn} project={project} />)
settings.openProject({
server: ServerConnection.key(conn),
project: project.worktree,
})
},
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
+32
View File
@@ -38,6 +38,38 @@
}
@layer components {
[data-slot="session-chat-panel"] {
container-name: session-chat;
}
[data-slot="session-chat-panel"]
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
translate: var(--session-summary-resize-translate, var(--session-summary-translate, none));
transition: translate 240ms cubic-bezier(0.4, 0, 0.2, 1);
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
[data-slot="session-chat-panel"][data-summary-resizing="true"]
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
transition: none;
}
/* Keep the 1000px conversation intact, with 320px for the summary and its gutters.
Move only as far as needed; wider panels already have enough space in the margin. */
@container session-chat (min-width: 1320px) {
[data-slot="session-chat-panel"][data-summary-open="true"]
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
--session-summary-translate: min(0px, calc(50cqi - 820px));
&:dir(rtl) {
--session-summary-translate: max(0px, calc(820px - 50cqi));
}
}
}
[data-slot="session-chat-panel"][data-scrollbar-hidden="true"]
[data-slot="session-timeline-scroll"]
> .scroll-view__thumb {
@@ -17,12 +17,14 @@ import { useSessionKey } from "@/session/session-layout"
import { showToast } from "@/shell/notifications/toast"
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/handoff"
import type { DraftMcpControls } from "./mcp"
export function createNewSessionComposerAdapter(props: {
draftID: string
worktree: () => string
branch: () => string | undefined
submitted: () => void
mcp: DraftMcpControls
}) {
const route = useSessionKey()
const prompt = useComposerState()
@@ -49,6 +51,7 @@ export function createNewSessionComposerAdapter(props: {
const projectDirectory = location().directory
const worktree = props.worktree()
const branch = props.branch()
const mcp = props.mcp.capture()
const id = Session.ID.create()
const pending =
worktree === "create"
@@ -68,6 +71,18 @@ export function createNewSessionComposerAdapter(props: {
return
}
const rollback = async () => {
if (!pending) return
data.project.invalidate()
await data.project.sync().catch(() => undefined)
await pending.rollback(sessionDirectory)
}
if (!(await props.mcp.prepare(sessionDirectory, mcp))) {
await rollback()
if (pending) props.mcp.remember(sessionDirectory, mcp)
return
}
const created = data.session.create({
id,
agent: selection.agent,
@@ -89,10 +104,7 @@ export function createNewSessionComposerAdapter(props: {
},
)
if (pending && !(await creation).ok) {
// Keep retries on the worktree that was already created, not another new checkout.
data.project.invalidate()
await data.project.sync().catch(() => undefined)
await pending.rollback(sessionDirectory)
await rollback()
return
}
const afterCreation = async <T>(run: () => Promise<T>) => {
+117
View File
@@ -0,0 +1,117 @@
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useMcpToggle, type McpControls } from "@/providers/connect/mcp"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useServer } from "@/runtime/server/current"
import { useTabs } from "@/shell/tabs/tabs"
import { showToast } from "@/shell/notifications/toast"
import { useWorkspaceLocation } from "@/workspaces/location"
export function createDraftMcpControls(input: { draftID: string; worktree: () => string }) {
const tabs = useTabs()
const location = useWorkspaceLocation()
const server = useServer()
const sdk = useServerSDK()
const language = useLanguage()
const [store, setStore] = createStore<{
preparing: boolean
pending: Record<string, Promise<boolean> | undefined>
}>({ preparing: false, pending: {} })
const key = (worktree: string) => JSON.stringify([server.key, location().directory, worktree])
const target = createMemo(() => key(input.worktree()))
const preview = () => input.worktree() === "create"
const directory = createMemo(() => {
const selected = input.worktree()
return selected === "main" || selected === "create" ? location().directory : selected
})
const states = createMemo(() => {
const draft = tabs.store.find((tab) => tab.type === "draft" && tab.draftID === input.draftID)
return draft?.type === "draft" && draft.mcp?.target === target() ? draft.mcp.states : {}
})
const toggle = useMcpToggle(directory)
const controls: McpControls = {
get preview() {
return preview()
},
get states() {
return states()
},
get pending() {
return store.preparing || (!preview() && store.pending[directory()] !== undefined)
},
change(name, enabled) {
if (controls.pending) return
tabs.updateDraft(input.draftID, { mcp: { target: target(), states: { ...states(), [name]: enabled } } })
if (preview()) return
const current = directory()
const request = toggle.mutateAsync({ name, enabled, directory: current }).then(
() => true,
() => false,
)
setStore("pending", current, request)
void request.finally(() => setStore("pending", current, undefined))
},
}
const apply = async (directory: string, states: Readonly<Record<string, boolean>>) => {
const pending = store.pending[directory]
if (pending && !(await pending)) return false
const entries = Object.entries(states)
if (entries.length === 0) return true
const catalog = await sdk.api.mcp.list({ location: { directory } })
const missing = entries.find(([name, enabled]) => enabled && !catalog.data.some((server) => server.name === name))
if (missing) throw new Error(language.t("session.summary.mcp.unavailable", { name: missing[0] }))
const results = await Promise.all(
entries
.filter(([name, enabled]) => {
const server = catalog.data.find((server) => server.name === name)
return server && (enabled ? server.status.status !== "connected" : server.status.status !== "disabled")
})
.map(([name, enabled]) =>
toggle.mutateAsync({ name, enabled, directory }).then(
() => true,
() => false,
),
),
)
if (results.some((success) => !success)) return false
const current = await sdk.api.mcp.list({ location: { directory } })
const unresolved = entries.find(([name, enabled]) => {
const status = current.data.find((server) => server.name === name)?.status.status
return enabled ? status !== "connected" : status !== undefined && status !== "disabled"
})
if (!unresolved) return true
const status = current.data.find((server) => server.name === unresolved[0])?.status.status
throw new Error(
language.t(status === "needs_auth" ? "session.summary.mcp.signInBeforeSend" : "session.summary.mcp.notReady", {
name: unresolved[0],
}),
)
}
return {
controls,
directory,
capture: () => ({ ...states() }),
remember(directory: string, states: Readonly<Record<string, boolean>>) {
tabs.updateDraft(input.draftID, { mcp: { target: key(directory), states: { ...states } } })
},
async prepare(directory: string, states: Readonly<Record<string, boolean>>) {
if (!store.pending[directory] && !Object.keys(states).length) return true
setStore("preparing", true)
return apply(directory, states)
.catch((error) => {
showToast({
variant: "error",
title: language.t("session.summary.mcp.prepareFailed"),
description: error instanceof Error ? error.message : String(error),
})
return false
})
.finally(() => setStore("preparing", false))
},
}
}
export type DraftMcpControls = ReturnType<typeof createDraftMcpControls>
+17 -7
View File
@@ -1,25 +1,34 @@
import { createPromptProjectController } from "@/new-session/project/selector"
import { useSettingsDialog } from "@/settings/command"
import { useSettings } from "@/settings/model"
import { useSettingsSurface } from "@/settings/surface"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { useSettingsServers } from "@/settings/servers/inventory"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, untrack } from "solid-js"
import { createComposerModel } from "@/composer/model"
import { useComposerCommands } from "@/composer/commands"
import { createNewSessionComposerAdapter } from "./composer-adapter"
import { NewSessionStatus, NewSessionView } from "./view"
import { NewSessionView } from "./view"
import { createNewSessionWorkspaceController } from "./workspace/controller"
import { useNewSessionCommands } from "./commands"
import { createDraftMcpControls } from "./mcp"
/** The draft-only Session page. Submitting promotes the draft into a real Session. */
export default function NewSessionPage(props: { draftId: string }) {
const settings = useSettings()
const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>()
const tabs = useTabs()
const openWorkspaces = useSettingsDialog("workspaces")
const servers = useSettingsServers()
const settingsSurface = useSettingsSurface()
const draftTab = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
)
const openWorkspaces = () => {
const draft = draftTab()
if (servers().length > 1 && draft) {
settingsSurface.openServer(draft.server, "workspaces")
return
}
settingsSurface.open("workspaces")
}
const workspace = createNewSessionWorkspaceController({
selectedWorktree: () => draftTab()?.worktree,
selectedBranch: () => draftTab()?.branch,
@@ -31,11 +40,13 @@ export default function NewSessionPage(props: { draftId: string }) {
},
onViewAll: openWorkspaces,
})
const mcp = createDraftMcpControls({ draftID: props.draftId, worktree: workspace.selection.value })
const composer = createNewSessionComposerAdapter({
draftID: props.draftId,
worktree: workspace.selection.value,
branch: workspace.bar.branch,
submitted: workspace.selection.remember,
mcp,
})
const model = createComposerModel(composer.adapter)
useComposerCommands({ model: composer.model })
@@ -72,9 +83,8 @@ export default function NewSessionPage(props: { draftId: string }) {
return (
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus visible={settings.visibility.status()} />
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<NewSessionView composer={model} project={project} workspace={workspace} />
<NewSessionView composer={model} project={project} workspace={workspace} mcp={mcp} />
</div>
</div>
)
+64
View File
@@ -0,0 +1,64 @@
import { Icon } from "@opencode/ui/icon"
import { Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { ProjectSummaryCard } from "@/session/summary/project-card"
import { SessionServerPanel } from "@/session/summary/server-panel"
import type { PromptProject } from "./project/selector"
import type { DraftMcpControls } from "./mcp"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { PromptWorkspaceSelector } from "./workspace/selector"
export function NewSessionSummary(props: {
project?: PromptProject
workspace: NewSessionWorkspaceController
mcp: DraftMcpControls
shown: boolean
onChooseProject: () => void
}) {
const language = useLanguage()
return (
<div data-component="session-summary-panel">
<Show
when={props.project}
fallback={
<div class="session-summary-card">
<button type="button" class="session-summary-row" onClick={props.onChooseProject}>
<Icon name="folder" class="text-v2-icon-icon-muted" />
<span class="session-summary-label">{language.t("session.summary.chooseProject")}</span>
</button>
</div>
}
>
{(project) => (
<>
<ProjectSummaryCard project={project()}>
<Show
when={props.workspace.bar.visible()}
fallback={
<div class="session-summary-row">
<Icon name="monitor" class="shrink-0 text-v2-icon-icon-muted" />
<span class="session-summary-label">{language.t("session.new.git.none")}</span>
</div>
}
>
<PromptWorkspaceSelector
variant="summary"
value={props.workspace.selection.value()}
projectRoot={props.workspace.project.root()}
workspaces={props.workspace.project.workspaces()}
branches={props.workspace.project.branches()}
branch={props.workspace.bar.branch()}
onChange={props.workspace.selection.set}
onCreate={props.workspace.selection.create}
onSearch={props.workspace.project.searchBranches}
onViewAll={props.workspace.project.openAll}
/>
</Show>
</ProjectSummaryCard>
<SessionServerPanel directory={props.mcp.directory()} shown={props.shown} mcp={props.mcp.controls} />
</>
)}
</Show>
</div>
)
}
+30 -16
View File
@@ -1,7 +1,8 @@
import { useDialog } from "@opencode/ui/context/dialog"
import { Tooltip } from "@opencode/ui/tooltip"
import { Icon } from "@opencode/ui/icon"
import { Show, createMemo, createSignal } from "solid-js"
import { Show, Suspense, createMemo, createSignal, lazy } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
@@ -13,8 +14,6 @@ import {
PromptProjectSelector,
type PromptProjectController,
} from "@/new-session/project/selector"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
@@ -23,6 +22,13 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { NewSessionWordmark } from "./wordmark"
import { SummaryPopover } from "@/session/summary/popover"
import type { DraftMcpControls } from "./mcp"
const NewSessionSummary = lazy(async () => {
const { NewSessionSummary } = await import("./summary")
return { default: NewSessionSummary }
})
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
@@ -40,7 +46,9 @@ export function NewSessionView(props: {
composer: ComposerModel
project: PromptProjectController
workspace: NewSessionWorkspaceController
mcp: DraftMcpControls
}) {
const [store, setStore] = createStore({ summary: false })
const [onboarding, setOnboarding, , onboardingReady] = persisted(
Persist.global("workspace-onboarding"),
WorkspaceOnboardingSchema,
@@ -61,6 +69,25 @@ export function NewSessionView(props: {
active={props.composer.state.drag === "active"}
input={props.composer.model.selection.current()?.capabilities.input}
/>
<div
data-slot="new-session-summary"
class="absolute inset-x-0 top-0 z-20 flex h-12 items-center justify-end px-3"
>
<SummaryPopover open={store.summary} onOpenChange={(open) => setStore("summary", open)}>
<Suspense>
<NewSessionSummary
project={props.project.selected()}
workspace={props.workspace}
mcp={props.mcp}
shown={store.summary}
onChooseProject={() => {
setStore("summary", false)
props.project.add()
}}
/>
</Suspense>
</SummaryPopover>
</div>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<NewSessionWordmark />
@@ -115,19 +142,6 @@ export function NewSessionView(props: {
)
}
export function NewSessionStatus(props: { visible: boolean }) {
const language = useLanguage()
return (
<TitlebarRight>
<Show when={props.visible}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
</TitlebarRight>
)
}
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
const language = useLanguage()
const dialog = useDialog()
@@ -15,13 +15,18 @@ export function PromptWorkspaceSelector(props: {
branches: string[]
branch?: string
onboarding?: boolean
variant?: "inline" | "summary"
onChange: (value: string) => void
onCreate: (branch: string) => void
onSearch: (search: string) => void
onDone: () => void
onDone?: () => void
onViewAll: () => void
}) {
const language = useLanguage()
const summary = () => props.variant === "summary"
const placement = createMemo(() =>
summary() ? (language.direction() === "rtl" ? "right-start" : "left-start") : "bottom",
)
const [search, setSearch] = createStore({ workspaces: "", branches: "" })
let searchInput: HTMLInputElement | undefined
let branchSearchInput: HTMLInputElement | undefined
@@ -58,17 +63,20 @@ export function PromptWorkspaceSelector(props: {
props.onViewAll()
return
}
props.onDone()
props.onDone?.()
}
const label = () => {
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
if (selected() === "main")
return language.t(summary() ? "session.new.workspace.local" : "session.new.workspace.triggerLocal")
if (props.value === "create") return language.t("workspace.new")
return getFilename(props.value)
}
return (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<Show when={!summary()}>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
</Show>
<Tooltip
appearance={props.onboarding ? "large" : undefined}
placement="top"
@@ -87,18 +95,28 @@ export function PromptWorkspaceSelector(props: {
)
}
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
class="min-w-0"
class={summary() ? "min-w-0 w-full" : "min-w-0"}
>
<Menu placement="bottom" gutter={4} overflowPadding={24} onOpenChange={onOpenChange}>
<Menu
placement={placement()}
gutter={4}
overflowPadding={24}
modal={summary() ? false : undefined}
onOpenChange={onOpenChange}
>
<Menu.Trigger
aria-description={language.t("session.new.workspace.trigger.tooltip")}
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
class={
summary()
? "session-summary-row"
: "flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
}
>
<Icon
name={icon()}
class={`shrink-0 ${selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
class={`shrink-0 ${summary() || selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<span class="min-w-0 truncate">{label()}</span>
<span class={summary() ? "session-summary-label" : "min-w-0 truncate"}>{label()}</span>
<Show when={props.onboarding}>
<span
data-slot="workspace-onboarding-dot"
@@ -106,7 +124,11 @@ export function PromptWorkspaceSelector(props: {
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
/>
</Show>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<Icon
name={summary() ? "fill-triangle-down" : "chevron-down"}
size={summary() ? "normal" : "small"}
class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted"
/>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="w-[200px]">
@@ -233,22 +255,47 @@ export function PromptWorkspaceSelector(props: {
</Tooltip>
<Show
when={selected() === "create" && props.branch}
fallback={<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />}
fallback={
summary() ? (
<Show when={props.branch}>
<div class="session-summary-row">
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<span dir="auto" class="session-summary-label">
{props.branch}
</span>
</div>
</Show>
) : (
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
)
}
>
<Tooltip
placement="top"
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
disabled={!branchTruncation.truncated()}
class="ms-1 min-w-0 max-w-[220px]"
class={summary() ? "min-w-0 w-full" : "ms-1 min-w-0 max-w-[220px]"}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<Menu placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={branchTruncation.observe} class="min-w-0 truncate">
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
<Menu placement={placement()} gutter={4} modal={summary() ? false : undefined} onOpenChange={onOpenChange}>
<Menu.Trigger
class={
summary()
? "session-summary-row"
: "flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted"
}
>
<Icon name="branch-out" size={summary() ? "normal" : "small"} class="shrink-0 text-v2-icon-icon-muted" />
<span ref={branchTruncation.observe} class={summary() ? "session-summary-label" : "min-w-0 truncate"}>
{language.t(summary() ? "session.summary.basedOn" : "session.new.workspace.fromBranch", {
branch: props.branch!,
})}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<Icon
name={summary() ? "fill-triangle-down" : "chevron-down"}
size={summary() ? "normal" : "small"}
class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted"
/>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content
+26 -14
View File
@@ -6,6 +6,13 @@ import { useServerSDK } from "@/runtime/server/client"
import { usePlatform } from "@/runtime/platform/platform"
import { showToast } from "@/shell/notifications/toast"
export type McpControls = {
readonly preview: boolean
readonly states: Readonly<Record<string, boolean>>
readonly pending: boolean
change: (name: string, enabled: boolean) => void
}
export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess?: () => unknown) {
const data = useData()
const serverSDK = useServerSDK()
@@ -17,31 +24,36 @@ export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess
}
return useMutation(() => ({
mutationFn: async (name: string) => {
const ref = location()
mutationFn: async (input: string | { name: string; enabled: boolean; directory?: string }) => {
const name = typeof input === "string" ? input : input.name
const ref = typeof input !== "string" && input.directory ? { directory: input.directory } : location()
const server = (await serverSDK.api.mcp.list({ location: ref })).data.find((item) => item.name === name)
if (!server || server.status.status === "pending") return
if (server.status.status === "connected") {
if (!server || (server.status.status === "pending" && typeof input === "string")) return
const enabled = typeof input === "string" ? server.status.status !== "connected" : input.enabled
if (!enabled) {
await serverSDK.api.mcp.disconnect({ server: name, location: ref })
} else if (server.status.status === "needs_auth" && server.integrationID) {
const integration = await serverSDK.api.integration.get({ integrationID: server.integrationID, location: ref })
}
if (enabled && server.status.status !== "needs_auth") {
await serverSDK.api.mcp.connect({ server: name, location: ref })
}
data.location.mcp.server.invalidate(ref)
await data.location.mcp.server.sync(ref)
const current = data.location.mcp.server.list(ref)?.find((item) => item.name === name)
if (enabled && current?.status.status === "needs_auth" && current.integrationID) {
const integration = await serverSDK.api.integration.get({ integrationID: current.integrationID, location: ref })
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length)
if (!method || method.type !== "oauth")
throw new Error(`MCP server ${name} requires an interactive authentication form`)
if (!method || method.type !== "oauth") throw new Error(language.t("mcp.auth.interactiveForm", { name }))
const attempt = await serverSDK.api.integration.oauth.connect({
integrationID: server.integrationID,
integrationID: current.integrationID,
methodID: method.id,
location: ref,
})
platform.openExternal(attempt.data.url)
} else {
await serverSDK.api.mcp.connect({ server: name, location: ref })
}
data.location.mcp.server.invalidate(ref)
data.location.mcp.resource.invalidate(ref)
await Promise.all([data.location.mcp.server.sync(ref), data.location.mcp.resource.sync(ref), onSuccess?.()])
await Promise.all([data.location.mcp.resource.sync(ref), onSuccess?.()])
// A successful HTTP response can still leave the MCP connection in a failed state.
const status = data.location.mcp.server.list(ref)?.find((item) => item.name === name)?.status
const status = current?.status
if (status?.status === "failed") throw new Error(`${name}: ${status.error}`)
},
onError: (error) =>
+2 -2
View File
@@ -149,7 +149,7 @@ export const DialogManageModels: Component = () => {
</span>
<span class="settings-models-group-label">
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
<span class="settings-section-title">{group.items[0].provider.name}</span>
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
</span>
</button>
<Switch
@@ -162,7 +162,7 @@ export const DialogManageModels: Component = () => {
</Switch>
</div>
<Show when={expanded()}>
<SettingsList>
<SettingsList variant="catalog">
<For each={group.items}>
{(item) => (
<SettingsRow title={item.name} description="">
@@ -162,12 +162,12 @@ const ModelList: Component<{
</span>
<span class="settings-models-group-label">
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
<span class="settings-section-title">{group.items[0].provider.name}</span>
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={open()}>
<SettingsList>
<SettingsList variant="catalog">
<For each={group.items}>
{(item) => (
<button
+78
View File
@@ -116,6 +116,7 @@ export const dict = {
"command.terminal.toggle": "Toggle terminal",
"command.fileTree.toggle": "Toggle file tree",
"command.review.toggle": "Toggle review",
"command.session.summary.toggle": "Toggle summary",
"command.terminal.new": "New terminal",
"command.terminal.new.description": "Create a new terminal tab",
"command.steps.toggle": "Toggle steps",
@@ -387,6 +388,7 @@ export const dict = {
"mcp.status.needs_auth": "needs auth",
"mcp.status.disabled": "disabled",
"mcp.auth.clickToAuthenticate": "Click to authenticate",
"mcp.auth.interactiveForm": "MCP server {{name}} requires an interactive authentication form",
"dialog.fork.empty": "No messages to fork from",
@@ -448,6 +450,7 @@ export const dict = {
"dialog.server.menu.default": "Set as default",
"dialog.server.menu.defaultRemove": "Remove default",
"dialog.server.menu.delete": "Delete",
"dialog.server.menu.remove": "Remove",
"dialog.server.menu.hide": "Hide from project list",
"dialog.server.menu.show": "Show in project list",
"dialog.server.current": "Current Server",
@@ -617,6 +620,7 @@ export const dict = {
"toast.model.none.description": "Connect a provider to summarize this session",
"toast.file.loadFailed.title": "Failed to load file",
"file.error.notFound": "File not found: {{name}}",
"toast.file.listFailed.title": "Failed to list files",
"toast.context.noLineSelection.title": "No line selection",
@@ -996,6 +1000,20 @@ export const dict = {
"settings.section.desktop": "Desktop",
"settings.section.server": "Server",
"settings.backToApp": "Back to app",
"settings.backToSettings": "Back to settings",
"settings.backToProjects": "Back to projects",
"settings.search.placeholder": "Search",
"settings.search.results": "Settings results",
"settings.search.group.pages": "Pages",
"settings.search.group.settings": "Settings",
"settings.search.group.projects": "Projects",
"settings.search.group.servers": "Servers",
"settings.search.result": "{{title}}, {{scope}}, {{page}}",
"settings.search.page": "{{title}}, {{scope}}",
"settings.search.result.unscoped": "{{title}}, {{page}}",
"settings.search.empty": "No results for {{query}}",
"settings.search.empty.query": '"{{query}}"',
"settings.search.refine": "Narrow your search to see more specific results.",
"settings.tab.general": "General",
"settings.tab.preferences": "Preferences",
"settings.tab.shortcuts": "Shortcuts",
@@ -1033,9 +1051,14 @@ export const dict = {
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
"settings.shortcuts.description": "Customize shortcuts for common actions",
"settings.servers.description": "Manage server connections",
"settings.server.description": "Manage this servers connection and preferences",
"settings.server.section.connection": "Connection",
"settings.server.preferences.websearch.title": "Third-party search",
"settings.server.preferences.websearch.description": "Select the search provider agents use to search the web",
"settings.projects.title": "Projects",
"settings.projects.description": "Manage project settings on this server",
"settings.projects.empty": "No projects found",
"settings.projects.search.placeholder": "Search projects",
"settings.projects.server.all": "All servers",
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
"settings.extensions.description": "Manage extensions available on this server",
@@ -1050,14 +1073,36 @@ export const dict = {
"dialog.server.authenticate.title": "Authenticate",
"project.settings.title": "Edit project",
"project.settings.general.description": "Manage project name and appearance",
"project.settings.name.title": "Project name",
"project.settings.name.description": "The name shown for this project throughout OpenCode",
"project.settings.icon.description": "Recommended: 128×128px. Click or drag to upload an image.",
"project.settings.color.description": "Used for the project icon when no custom image is set",
"project.settings.worktree.startup.description": "Runs once after creating a new worktree",
"project.settings.worktree.startup.hint.base": "Use $OPENCODE_WORKTREE_BASE for the base worktree.",
"project.settings.worktree.startup.hint.new": "Use $OPENCODE_WORKTREE_PATH for the new worktree.",
"project.settings.scripts": "Scripts",
"project.settings.scripts.description": "Configure scripts for this project",
"project.settings.extensions.description": "View extensions available to this project",
"project.settings.extensions.tab.lsps": "LSPs",
"project.settings.extensions.added": "Added to this project",
"project.settings.extensions.shared": "Shared with all projects",
"project.settings.extensions.empty.mcps.title": "No MCPs yet",
"project.settings.extensions.empty.mcps.description": "MCPs available to OpenCode will appear here",
"project.settings.extensions.empty.plugins.title": "No plugins yet",
"project.settings.extensions.empty.plugins.description": "Plugins available to OpenCode will appear here",
"project.settings.extensions.empty.skills.title": "No skills yet",
"project.settings.extensions.empty.skills.description": "Skills available to OpenCode will appear here",
"project.settings.extensions.lsp.detected": "Detected language servers",
"project.settings.extensions.lsp.description": "Auto-detected from file types",
"project.settings.extensions.lsp.configured": "Configured language servers",
"project.settings.extensions.lsp.status.enabled": "Enabled in config",
"project.settings.extensions.lsp.status.disabled": "Disabled in config",
"project.settings.extensions.lsp.empty.title": "No language servers configured",
"project.settings.extensions.lsp.empty.description": "Language servers configured for this project will appear here",
"project.settings.extensions.lsp.disabled.title": "Language servers disabled",
"project.settings.extensions.lsp.disabled.description": "LSP is disabled in this projects configuration",
"project.settings.extensions.lsp.loadFailed": "Could not load language server configuration",
"project.settings.extensions.lsp.retry": "Retry",
"project.settings.extensions.setupRequired": "Setup required",
"settings.general.section.appearance": "Appearance",
@@ -1383,8 +1428,41 @@ export const dict = {
"workspace.lifecycle.moving": "Moving to worktree",
"workspace.lifecycle.set": "Worktree set",
"session.summary.title": "Session details",
"session.summary.tooltip": "Summary",
"session.summary.noBranch": "No branch",
"session.summary.basedOn": "Based on {{branch}}",
"session.summary.server": "Extensions",
"session.summary.chooseProject": "Choose a project",
"session.summary.mcp.onCreation": "Applies when the worktree is created",
"session.summary.mcp.prepareFailed": "Could not prepare MCP servers",
"session.summary.mcp.unavailable": "MCP server {{name}} is not available in this worktree.",
"session.summary.mcp.signInBeforeSend": "Sign in to {{name}} before sending the prompt.",
"session.summary.mcp.notReady": "MCP server {{name}} is not ready. Resolve its connection before sending the prompt.",
"session.summary.mcp": "MCP",
"session.summary.mcp.title": "Configured MCP servers",
"session.summary.plugins": "Plugins",
"session.summary.plugins.configured": "Configured plugins",
"session.summary.skills": "Skills",
"session.summary.skills.configured": "Configured skills",
"session.summary.lsp": "LSP",
"session.summary.failed": "Failed",
"session.summary.retry": "Retry",
"session.summary.connecting": "Connecting…",
"session.summary.needsAuth": "Sign in required",
"session.summary.configure": "Configuration file",
"session.summary.copyConfigPath": "Copy configuration file path",
"session.summary.configFileMissing": "No configuration file found",
"session.summary.mcp.empty": "No MCP servers configured",
"session.summary.mcp.add": "Add servers in opencode.json",
"session.summary.plugins.manage": "Manage plugins in opencode.json",
"session.summary.plugins.empty": "No plugins configured",
"session.summary.plugins.add": "Add plugins in opencode.json",
"session.summary.skills.manage": "Manage skills in opencode.json",
"session.summary.skills.empty": "No skills configured",
"session.summary.skills.add": "Add skills in opencode.json",
"session.summary.lsp.configured": "Configured LSPs",
"session.summary.lsp.empty": "No LSP servers configured",
"session.summary.lsp.manage": "Manage LSP in opencode.json",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Failed to create worktree",
@@ -221,6 +221,11 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
params?: Record<string, string | number | boolean>,
) => string
const rich = (key: Parameters<typeof t>[0], params: Record<string, JSX.Element>) =>
t(key)
.split(/(\{\{\w+\}\})/g)
.map((part, index) => (index % 2 ? (params[part.slice(2, -2)] ?? part) : part))
const pluralForm = (
key: PluralKey,
category: UiPluralCategory,
@@ -262,6 +267,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
locales: LOCALES,
label,
t,
rich,
plural,
pluralForm,
setLocale(next: Locale) {
+3
View File
@@ -0,0 +1,3 @@
// Safari before 17.4 does not provide these runtime APIs.
import "core-js/es/map/group-by"
import "core-js/es/promise/with-resolvers"
@@ -90,7 +90,7 @@ test("rotates HTTP and PTY clients together", async () => {
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ url: request.url, authorization: request.headers.get("authorization") })
return Response.json({ healthy: true, version: "2.0.0-test", pid: 1 })
return Response.json({ version: "2.0.0-test", pid: 1, urls: [request.url] })
}) as typeof globalThis.fetch
const transport = createServerTransport({
http: { url: "http://127.0.0.1:4100", password: "first" },
@@ -98,23 +98,23 @@ test("rotates HTTP and PTY clients together", async () => {
})
const initialPty = transport.pty
await transport.api.health.get()
await transport.api.server.status()
const replacement = transport.update({
url: "http://127.0.0.1:4200",
password: "second",
})
await transport.api.health.get()
await transport.api.server.status()
expect(replacement).toBe(transport.api)
expect(transport.pty).not.toBe(initialPty)
expect(transport.url).toBe("http://127.0.0.1:4200")
expect(requests).toEqual([
{
url: "http://127.0.0.1:4100/api/health",
url: "http://127.0.0.1:4100/api/status",
authorization: `Basic ${btoa("opencode:first")}`,
},
{
url: "http://127.0.0.1:4200/api/health",
url: "http://127.0.0.1:4200/api/status",
authorization: `Basic ${btoa("opencode:second")}`,
},
])
+11 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SessionNotFoundError } from "@opencode/client/promise"
import type { FileNotFoundError, SessionNotFoundError } from "@opencode/client/promise"
import type { ConfigInvalidError, ProviderModelNotFoundError } from "./errors"
import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./errors"
@@ -87,6 +87,16 @@ describe("formatServerError", () => {
)
})
test("returns typed server error messages", () => {
const error = {
_tag: "FileNotFoundError",
path: "deleted.txt",
message: "File not found: deleted.txt",
} satisfies FileNotFoundError
expect(formatServerError(error, language.t)).toBe("File not found: deleted.txt")
})
test("returns provided string errors", () => {
expect(formatServerError("Failed to connect to server", language.t)).toBe("Failed to connect to server")
})
@@ -29,6 +29,14 @@ export function formatServerError(error: unknown, translate?: Translator, fallba
const unwrapped = unwrapNamedError(error)
if (isConfigInvalidErrorLike(unwrapped)) return parseReadableConfigInvalidError(unwrapped, translate)
if (isProviderModelNotFoundErrorLike(unwrapped)) return parseReadableProviderModelNotFoundError(unwrapped, translate)
if (
typeof unwrapped === "object" &&
unwrapped !== null &&
"message" in unwrapped &&
typeof unwrapped.message === "string" &&
unwrapped.message
)
return unwrapped.message
if (error instanceof Error && error.message) return error.message
if (typeof error === "string" && error) return error
if (fallback) return fallback
@@ -2,8 +2,6 @@ import type { Config, Path, Project, ProviderAuthResponse } from "@/runtime/serv
import type {
LocationGetInput,
LocationGetOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectListOutput,
} from "@opencode/client/promise"
import { showToast } from "@/shell/notifications/toast"
@@ -61,7 +59,6 @@ export const loadGlobalConfigQuery = (scope: ServerScope) =>
type ProjectApi = {
readonly list: () => Promise<ProjectListOutput>
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
}
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
@@ -132,6 +129,7 @@ export async function bootstrapDirectory(input: {
mcp: boolean
api: {
readonly project: ProjectApi
readonly location: LocationApi
}
store: Store<State>
setStore: SetStoreFunction<State>
@@ -155,8 +153,8 @@ export async function bootstrapDirectory(input: {
seededProject
? undefined
: () =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
input.setStore("project", project.id),
retry(() => input.api.location.get({ location: { directory: input.directory } })).then((location) =>
input.setStore("project", location.project.id),
),
].filter((task): task is () => Promise<void> => !!task)
+9 -30
View File
@@ -17,11 +17,10 @@ describe("checkServerHealth", () => {
const headers: Array<string | null> = []
const fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
headers.push(new Headers(init?.headers).get("authorization"))
return Response.json({ healthy: true, version: "2.0.0" })
return Response.json({ version: "2.0.0", pid: 1, urls: [server.url] })
}) as typeof globalThis.fetch
const legacy = { ...server, username: "legacy", password }
expect(await checkServerHealth(legacy, fetch)).toEqual({ healthy: true, version: "2.0.0" })
expect(await checkServerHealth({ ...server, password }, fetch)).toEqual({ healthy: true, version: "2.0.0" })
expect(headers).toEqual([password ? `Basic ${btoa(`opencode:${password}`)}` : null])
})
@@ -29,7 +28,7 @@ describe("checkServerHealth", () => {
let request: URL | undefined
const fetch = (async (input: RequestInfo | URL) => {
request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
@@ -38,27 +37,7 @@ describe("checkServerHealth", () => {
const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" })
expect(request?.pathname).toBe("/api/health")
})
test("identifies a V1 server without a version as incompatible", async () => {
const requests: string[] = []
const fetch = (async (input: RequestInfo | URL) => {
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
requests.push(url.pathname)
return new Response(
JSON.stringify(url.pathname === "/global/health" ? { version: "1.18.15" } : { healthy: true }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: false, version: "1.18.15", incompatible: true })
expect(requests).toEqual(["/api/health", "/global/health"])
expect(request?.pathname).toBe("/api/status")
})
test("allows slow servers thirty seconds by default", async () => {
@@ -73,14 +52,14 @@ describe("checkServerHealth", () => {
})
const fetch = (async () =>
new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch
await checkServerHealth(server, fetch).finally(() => {
if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout)
if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout")
if (!timeout) delete (AbortSignal as Partial<typeof AbortSignal>).timeout
})
expect(timeoutMs).toBe(30_000)
@@ -121,7 +100,7 @@ describe("checkServerHealth", () => {
timeoutMs: 10,
}).finally(() => {
if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout)
if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout")
if (!timeout) delete (AbortSignal as Partial<typeof AbortSignal>).timeout
})
expect(aborted).toBe(true)
@@ -132,7 +111,7 @@ describe("checkServerHealth", () => {
let signal: AbortSignal | undefined
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
signal = abortFromInput(input, init)
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
@@ -151,7 +130,7 @@ describe("checkServerHealth", () => {
const fetch = (async () => {
count += 1
if (count < 3) throw new TypeError("network")
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
+3 -16
View File
@@ -95,23 +95,10 @@ export async function checkServerHealth(
fetch,
headers,
})
.health.get({ signal })
.then(async (x) => {
if (typeof x.healthy !== "boolean") return { error: new Error("Invalid health response") }
if (x.healthy && typeof x.version !== "string") {
const legacy = await fetch(new URL("/global/health", server.url), { headers, signal })
.then((response) => response.json())
.catch(() => undefined)
const version =
typeof legacy === "object" && legacy !== null && "version" in legacy && typeof legacy.version === "string"
? legacy.version
: "1"
return { data: { healthy: false, version, incompatible: true } }
}
return { data: { healthy: x.healthy, version: x.version } }
})
.server.status({ signal })
.then((status) => ({ data: { healthy: true as const, version: status.version } }))
.catch((error) => ({ error }))
if ("data" in current && current.data) return current.data
if ("data" in current) return current.data
if (signal?.aborted) return { healthy: false }
return next(count, current.error)
@@ -102,7 +102,14 @@ describe("readLocalImage", () => {
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toEqual(error)
})
test.each([404, 500])("does not turn an unexpected HTTP status (%s) into a Blob", async (status) => {
test("propagates missing-file errors", async () => {
const error = { _tag: "FileNotFoundError", path: "image.png", message: "File not found: image.png" }
const { api } = setup(() => Response.json(error, { status: 404 }))
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toEqual(error)
})
test("does not turn an unexpected HTTP status into a Blob", async () => {
const status = 500
const { api } = setup(() => new Response("Not an image", { status }))
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject({
reason: "UnexpectedStatus",
@@ -195,7 +195,7 @@ describe("createRequestQueue", () => {
input.tick(50)
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined)
input.tick(100)
input.queue.fetch("http://server/api/health").catch(() => undefined)
input.queue.fetch("http://server/api/status").catch(() => undefined)
expect(input.logs).toEqual([])
input.tick(2_000)
await new Promise((resolve) => setTimeout(resolve, 20))
@@ -210,7 +210,7 @@ describe("createRequestQueue", () => {
],
queued: [
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 },
{ method: "GET", url: "http://server/api/health", ms: 2_000 },
{ method: "GET", url: "http://server/api/status", ms: 2_000 },
],
},
},
@@ -1,6 +1,5 @@
import { createSimpleContext } from "@opencode/ui/context"
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
import { pathKey } from "@/workspaces/path-key"
import { useServerHealth } from "@/runtime/server/health"
@@ -28,25 +27,9 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
() => server.list,
() => true,
)
const [store, setStore] = createStore({
settings: {
serverKey: undefined as ServerConnection.Key | undefined,
},
})
const models = createGlobalModels()
const notificationCoordinator = createNotificationCoordinator()
const settingsServer = createMemo(() => {
const list = server.list
return list.find((conn) => ServerConnection.key(conn) === store.settings.serverKey) ?? list[0]
})
createEffect(() => {
const conn = settingsServer()
const key = conn ? ServerConnection.key(conn) : undefined
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
})
const serverCtxs = new Map<ServerConnection.Key, ReturnType<typeof createServerController>>()
const serverCtxDisposers = new Map<ServerConnection.Key, () => void>()
@@ -86,17 +69,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
list: () => server.list,
health: serverHealth,
},
settings: {
server: {
get key() {
return store.settings.serverKey
},
selected: settingsServer,
set(key: ServerConnection.Key) {
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
},
},
},
models,
ensureServerCtx(conn: ServerConnection.Any) {
return ensureServerCtx(conn)
+8 -3
View File
@@ -25,11 +25,15 @@ type FormMode = "list" | "add" | "edit"
export const DialogServer: Component<{
mode: "add" | "edit"
server?: ServerConnection.Http
onSave?: (server: ServerConnection.Http) => void
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const form = createFormController({
onSelect: () => dialog.close(),
onSelect: (server) => {
props.onSave?.(server)
dialog.close()
},
})
const [opened, setOpened] = createSignal(false)
@@ -133,7 +137,7 @@ export const DialogServer: Component<{
)
}
function createFormController(options: { onSelect?: () => void } = {}) {
function createFormController(options: { onSelect?: (server: ServerConnection.Http) => void } = {}) {
const platform = usePlatform()
const server = useServers()
const tabs = useTabs()
@@ -220,13 +224,14 @@ function createFormController(options: { onSelect?: () => void } = {}) {
if (original?.type === "http") {
if (normalized === original.http.url) add(connection)
if (normalized !== original.http.url) replace(ServerConnection.key(original), connection)
options.onSelect?.(connection)
reset()
return
}
reset()
add(connection)
options.onSelect?.()
options.onSelect?.(connection)
},
}))
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/runtime/server/registry"
import { sortServerConnections } from "./controller"
const server = (url: string): ServerConnection.Http => ({ type: "http", http: { url } })
describe("sortServerConnections", () => {
test("places the default first and preserves health and insertion ordering", () => {
const first = server("http://first")
const offline = server("http://offline")
const preferred = server("http://preferred")
const unknown = server("http://unknown")
const result = sortServerConnections({
servers: [first, offline, preferred, unknown],
health: {
[ServerConnection.key(first)]: { healthy: true },
[ServerConnection.key(offline)]: { healthy: false },
},
defaultKey: ServerConnection.key(preferred),
})
expect(result).toEqual([preferred, first, unknown, offline])
})
})
+30 -22
View File
@@ -1,10 +1,8 @@
import { useNavigate } from "@solidjs/router"
import { createMemo, createResource } from "solid-js"
import { useGlobal } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useSettings } from "@/settings/model"
import { useTabs } from "@/shell/tabs/tabs"
import { type ServerHealth } from "@/runtime/server/health"
import { showToast } from "@/shell/notifications/toast"
@@ -49,6 +47,27 @@ function useDefaultServer() {
}
}
export function sortServerConnections(input: {
servers: ServerConnection.Any[]
health: Record<string, ServerHealth | undefined>
defaultKey: ServerConnection.Key | null
}) {
const order = new Map(input.servers.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return input.servers.slice().sort((a, b) => {
const preferred =
Number(ServerConnection.key(b) === input.defaultKey) - Number(ServerConnection.key(a) === input.defaultKey)
if (preferred !== 0) return preferred
const health = rank(input.health[ServerConnection.key(a)]) - rank(input.health[ServerConnection.key(b)])
if (health !== 0) return health
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
}
export function useServerActionsController() {
const server = useServers()
const ssh = useSsh()
@@ -78,8 +97,8 @@ export function useServerActionsController() {
const conn = server.list.find((item) => ServerConnection.key(item) === key)
return server.visible.length > 1 && !!conn && ServerConnection.builtin(conn)
},
isHidden: server.isHidden,
setHidden: server.setHidden,
isHidden: (key: ServerConnection.Key) => server.isHidden(key),
setHidden: (key: ServerConnection.Key, hidden: boolean) => server.setHidden(key, hidden),
},
}
}
@@ -89,27 +108,16 @@ export type ServerActionsController = ReturnType<typeof useServerActionsControll
export function useServerCollectionController() {
const server = useServers()
const global = useGlobal()
const settings = useSettings()
const actions = useServerActionsController()
const items = createMemo(() => server.list)
const sorted = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const order = new Map(list.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
const sorted = createMemo(() =>
sortServerConnections({
servers: items(),
health: global.servers.health,
defaultKey: actions.defaults.key(),
}),
)
return {
collection: {
@@ -45,7 +45,7 @@ export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
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"),
remove: language.t("dialog.server.menu.remove"),
hide: language.t("dialog.server.menu.hide"),
show: language.t("dialog.server.menu.show"),
}
@@ -106,7 +106,7 @@ export const ServerRowMenuView: Component<{
</Show>
<Show when={props.canRemove}>
<Menu.Separator />
<Menu.Item onSelect={props.onRemove}>{props.labels.delete}</Menu.Item>
<Menu.Item onSelect={props.onRemove}>{props.labels.remove}</Menu.Item>
</Show>
</Menu.Group>
</Menu.Content>
+4 -2
View File
@@ -11,14 +11,16 @@ import { Spinner } from "@opencode/ui/spinner"
import { sshName } from "./name"
import { isSshConnecting } from "./status"
export function SshServerSettings(props: { filter: string; domain: ServerCollectionController }) {
export function SshServerSettings(props: { filter: string; id?: string; domain: ServerCollectionController }) {
const ssh = useSsh()
const language = useLanguage()
return (
<For
each={ssh.servers.filter(
(item) =>
item.saved && `${item.config.name} ${item.config.target}`.toLowerCase().includes(props.filter.toLowerCase()),
item.saved &&
(!props.id || item.config.id === props.id) &&
`${item.config.name} ${item.config.target}`.toLowerCase().includes(props.filter.toLowerCase()),
)}
>
{(item) => {
+38 -9
View File
@@ -16,13 +16,14 @@ import { showToast } from "@/shell/notifications/toast"
import { DialogAddWslServer } from "./dialog"
import { useWslServers } from "./context"
import { wslOpencodeAction, wslRuntimeRetryable } from "./model"
import type { WslServerItem } from "./types"
import { DialogSsh } from "../ssh/dialog"
export function isWslServer(server: ServerConnection.Any) {
return server.type === "sidecar" && server.variant === "wsl"
}
export function AddServerMenu(props: { onAddServer: () => void }) {
export function AddServerMenu(props: { onAddServer: () => void; compact?: boolean }) {
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
@@ -33,15 +34,41 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
<Show
when={platform.wslServers || platform.sshServers}
fallback={
<Button variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
{language.t("dialog.server.add.button")}
</Button>
<Show
when={props.compact}
fallback={
<Button variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
{language.t("dialog.server.add.button")}
</Button>
}
>
<IconButton
variant="ghost-muted"
size="small"
icon={<Icon name="plus" />}
aria-label={language.t("dialog.server.add.button")}
onClick={props.onAddServer}
/>
</Show>
}
>
<Menu gutter={4} modal={false} placement="bottom-end">
<Menu.Trigger as={Button} variant="ghost-muted" icon="plus">
{language.t("dialog.server.add.button")}
</Menu.Trigger>
<Show
when={props.compact}
fallback={
<Menu.Trigger as={Button} variant="ghost-muted" icon="plus">
{language.t("dialog.server.add.button")}
</Menu.Trigger>
}
>
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
size="small"
icon={<Icon name="plus" />}
aria-label={language.t("dialog.server.add.button")}
/>
</Show>
<Menu.Portal>
<Menu.Content>
<Menu.Item onSelect={props.onAddServer}>{language.t("dialog.server.add.button")}</Menu.Item>
@@ -72,7 +99,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
export function WslServerSettings(props: {
domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
servers: ReturnType<typeof useFilteredWslServers>
servers: Accessor<readonly WslServerItem[]>
}) {
const platform = usePlatform()
const language = useLanguage()
@@ -160,7 +187,9 @@ export function WslServerSettings(props: {
</Menu.Item>
</Show>
<Menu.Separator />
<Menu.Item onSelect={() => remove(key)}>{language.t("dialog.server.menu.delete")}</Menu.Item>
<Menu.Item disabled={request.isPending} onSelect={() => remove(key)}>
{language.t("dialog.server.menu.remove")}
</Menu.Item>
</Menu.Group>
</Menu.Content>
</Menu.Portal>

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