From 2b34df94fa0faa7849959fc0a90328b542327812 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:28:28 -0500 Subject: [PATCH 01/19] fix(mcp): preserve metadata across tool pages (#35439) --- packages/opencode/test/mcp/catalog.test.ts | 62 ++++++++++++++++++- .../@modelcontextprotocol%2Fsdk@1.29.0.patch | 50 +++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 55cabaef7699..7b0d6403bb16 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test" -import type { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { McpCatalog } from "@/mcp/catalog" +import { Effect } from "effect" const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any @@ -45,3 +49,59 @@ describe("McpCatalog.convertTool", () => { }) }) }) + +test("preserves output schema validation across paginated tool discovery", async () => { + const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, ({ params }) => + Promise.resolve( + params?.cursor === "page-2" + ? { + tools: [ + { + name: "second", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "number" } }, + required: ["value"], + }, + }, + ], + } + : { + tools: [ + { + name: "first", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }, + ], + nextCursor: "page-2", + }, + ), + ) + server.setRequestHandler(CallToolRequestSchema, ({ params }) => + Promise.resolve({ + content: [], + structuredContent: { value: params.name === "first" ? 42 : 1 }, + }), + ) + + const client = new Client({ name: "pagination-test", version: "1.0.0" }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) + + try { + const tools = await Effect.runPromise(McpCatalog.defs(client)) + expect(tools?.map((tool) => tool.name)).toEqual(["first", "second"]) + await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow( + "Structured content does not match the tool's output schema", + ) + } finally { + await Promise.all([client.close(), server.close()]) + } +}) diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch index e38e68e75d49..13b8000a0139 100644 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -112,6 +112,31 @@ index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c1 /** * After initialization has completed, this will be populated with the server's reported capabilities. */ +@@ -541,9 +547,11 @@ class Client extends protocol_js_1.Protocol { + * Called after listTools() to pre-compile validators for better performance. + */ +- cacheToolMetadata(tools) { +- this._cachedToolOutputValidators.clear(); +- this._cachedKnownTaskTools.clear(); +- this._cachedRequiredTaskTools.clear(); ++ cacheToolMetadata(tools, reset = true) { ++ if (reset) { ++ this._cachedToolOutputValidators.clear(); ++ this._cachedKnownTaskTools.clear(); ++ this._cachedRequiredTaskTools.clear(); ++ } + for (const tool of tools) { + // If the tool has an outputSchema, create and cache the validator + if (tool.outputSchema) { +@@ -569,7 +577,7 @@ class Client extends protocol_js_1.Protocol { + async listTools(params, options) { + const result = await this.request({ method: 'tools/list', params }, types_js_1.ListToolsResultSchema, options); + // Cache the tools and their output schemas for future validation +- this.cacheToolMetadata(result.tools); ++ this.cacheToolMetadata(result.tools, params?.cursor === undefined); + return result; + } + /** diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp.js index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eedff5443c9 100644 --- a/dist/cjs/client/streamableHttp.js @@ -461,6 +486,31 @@ index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8e /** * After initialization has completed, this will be populated with the server's reported capabilities. */ +@@ -537,9 +543,11 @@ export class Client extends Protocol { + * Called after listTools() to pre-compile validators for better performance. + */ +- cacheToolMetadata(tools) { +- this._cachedToolOutputValidators.clear(); +- this._cachedKnownTaskTools.clear(); +- this._cachedRequiredTaskTools.clear(); ++ cacheToolMetadata(tools, reset = true) { ++ if (reset) { ++ this._cachedToolOutputValidators.clear(); ++ this._cachedKnownTaskTools.clear(); ++ this._cachedRequiredTaskTools.clear(); ++ } + for (const tool of tools) { + // If the tool has an outputSchema, create and cache the validator + if (tool.outputSchema) { +@@ -565,7 +573,7 @@ export class Client extends Protocol { + async listTools(params, options) { + const result = await this.request({ method: 'tools/list', params }, ListToolsResultSchema, options); + // Cache the tools and their output schemas for future validation +- this.cacheToolMetadata(result.tools); ++ this.cacheToolMetadata(result.tools, params?.cursor === undefined); + return result; + } + /** diff --git a/dist/esm/client/streamableHttp.js b/dist/esm/client/streamableHttp.js index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da884fcc627a 100644 --- a/dist/esm/client/streamableHttp.js From e0ec9be238a1495454e46426665323af25273b63 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 6 Jul 2026 03:49:25 +0000 Subject: [PATCH 02/19] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index d17f7db33701..a86dbdec4f20 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-ovrz0pALxRek5VEqSYkVpzb9YeiiVQWxfAHZQ3kX0/0=", - "aarch64-linux": "sha256-+sqBt11Nl1fDrL1JvFAcN8JMWahspfbBPuMeM7YdagU=", - "aarch64-darwin": "sha256-PsZfcmMpz/Xzudjv4CQZu+wPfnyijrlVYeP2x9tRffk=", - "x86_64-darwin": "sha256-4TpIc3Gh9nDaA5Y1zkCq0KraMdWu+PWtYVY3p7/CsJ0=" + "x86_64-linux": "sha256-NRSHbXA5jYZ2VUQ+b7d/dwg5BWap54ej3Ys9MPwgLwc=", + "aarch64-linux": "sha256-ua9ZjsF0KDuLOPjEcxhD4u5cMfjPuqoAyUftrNPr2sI=", + "aarch64-darwin": "sha256-QH3as89//yrRpWK7f96rNjiwCO5JTDYhVrUHbqQMomw=", + "x86_64-darwin": "sha256-30cIeLqasI+FUBYBttb3+MezWoQmb+UpkFWhPy4B3Xk=" } } From d4f70399323131620e3bf486af17f36828c08904 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:52:37 -0500 Subject: [PATCH 03/19] fix(codemode): unify catalog signatures (#35452) --- packages/codemode/README.md | 27 +- packages/codemode/codemode.md | 102 +++--- packages/codemode/src/codemode.ts | 18 +- packages/codemode/src/tool-runtime.ts | 298 ++++++++---------- packages/codemode/src/tool.ts | 4 +- packages/codemode/test/codemode.test.ts | 195 ++++++++---- packages/codemode/test/enumeration.test.ts | 14 +- packages/codemode/test/signature.test.ts | 72 ++--- packages/opencode/src/tool/code-mode.ts | 11 +- .../test/tool/code-mode-integration.test.ts | 6 +- packages/opencode/test/tool/code-mode.test.ts | 42 ++- packages/opencode/test/tool/registry.test.ts | 2 +- 12 files changed, 430 insertions(+), 361 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 0be6d769c7cb..afd178a36cff 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -179,14 +179,14 @@ Supported bearer, basic, header, and query authentication follows OpenAPI `secur ## Discovery -The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature line against the shared budget, and a namespace whose next line does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`). +The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete, JSDoc-annotated tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Schema field descriptions and tags are part of each signature's measured cost. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature against the shared budget, and a namespace whose next signature does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`). -The default budget is 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). Override it when constructing a runtime: +The catalog-entry budget defaults to 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). It applies only to full tool entries shown in the catalog; fixed instructions and namespace summaries are not counted. Override it when constructing a runtime: ```ts const runtime = CodeMode.make({ tools, - discovery: { maxInlineCatalogTokens: 6_000 }, + discovery: { catalogBudget: 6_000 }, }) ``` @@ -199,30 +199,37 @@ const matches = await tools.$codemode.search({ query: "order status", namespace: "orders", // optional: scope to one top-level namespace limit: 10, + offset: 0, }) ``` -`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches - so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path) and capped at `limit` results (default 10). +`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches - so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path), then sliced from the zero-based `offset` (default 0) to the configured `limit` (default 10). `remaining` counts matches after the current page. `next` is `{ offset }` when another page exists and `null` on the final page; spread it into the original request to preserve its query, namespace, and limit. -Each result contains the path, description, and generated TypeScript signature, so no second lookup is needed. The result signature is the pretty, JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** ... */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). The inline catalog in the instructions keeps the compact single-line form. +```ts +const request = { query: "order status", namespace: "orders", limit: 10 } +const page = await tools.$codemode.search(request) +const nextPage = page.next ? await tools.$codemode.search({ ...request, ...page.next }) : undefined +``` + +Each result contains the path, description, and the same generated TypeScript signature used by the inline catalog, so no second lookup is needed. Signatures use the JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** ... */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). ```ts tools.github.list_issues(input: { /** Repository owner */ - owner: string + owner: string, /** Cursor from the previous response's pageInfo */ - after?: string + after?: string, /** * Results per page * @default 30 */ - perPage?: number + perPage?: number, }): Promise ``` Result paths are rendered as JavaScript expressions rooted at `tools` (`tools.orders.lookup`, or `tools.context7["resolve-library-id"]` for non-identifier segments), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (canonical path, `tools.`-prefixed path, or rendered JavaScript expression) is treated as a lookup and returns that tool alone. -The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; `JSON.parse` string results; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result tools exist inside `tools`; filter and aggregate collections in code; treat `Promise` results as shapeless until verified; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace via search when it is advertised), a short `## Syntax` section that assumes standard JavaScript and names only what is unusual (TypeScript annotations stripped; the data-boundary serialization of Date/Map/Set/RegExp) or missing (classes, generators, `for await...of`, `.then`/`.catch`/`.finally`), and the budgeted `## Available tools` catalog. Example call forms use explicit `.`/`` placeholders - never a real or fabricated tool name. +The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result Code Mode tools and internal runtime tools exist inside `tools`; filter and aggregate collections in code; narrow `Promise` results at runtime; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace and paginate search results when search is advertised), a short `## Language` section that identifies the runtime as a restricted JavaScript orchestration language and names its major unavailable capabilities, and the budgeted `## Available tools` catalog. Example call forms use explicit `.`/`` placeholders - never a real or fabricated tool name. A host cannot define its own `$codemode` top-level namespace. @@ -234,7 +241,7 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports: - `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`. - Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring. - Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`. -- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces and `Object.keys(tools.ns)` the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. +- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. - `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones). - Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. Function replacers are not supported. - `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators). diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 732c565b72dc..f42c160cad4e 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -65,17 +65,24 @@ From issue #34787 and design discussion. Do not relitigate these casually. ### Discovery / search - **Search only - no separate `describe`.** `tools.$codemode.search({ query?, namespace?, -limit? })` over the final tool tree, owned by this package. -- Search result item shape: `{ path, description, signature }` in an `{ items, total }` - wrapper. The `signature` string embeds the full input/output TypeScript types - in search - results it is the pretty, JSDoc-annotated multiline form (Fix 7), so per-field schema - `description`s and constraints (`@default`, `@format`, `@deprecated`, `@minItems`, - `@maxItems`) ride along as field comments. The original spec's separate `input`/`output` +limit?, offset? })` over the final tool tree, owned by this package. +- Search result item shape: `{ path, description, signature }` in an + `{ items, remaining, next }` + wrapper. The `signature` string embeds the full input/output TypeScript types and uses the + same pretty, JSDoc-annotated multiline form in inline catalogs and search results, so + per-field schema `description`s and constraints (`@default`, `@format`, `@deprecated`, + `@minItems`, `@maxItems`) ride along as field comments. The original spec's separate `input`/`output` raw-schema fields are deliberately NOT added: shapes are already fully expressed in the TypeScript signature and schema annotations now arrive as JSDoc - intent satisfied, letter deviated. Result `path`s render a JavaScript expression rooted at `tools` (for example `tools.github.list_issues` or `tools.context7["resolve-library-id"]`) so each is directly usable as the call site; the internal `ToolDescription.path` stays unprefixed. +- `offset` is zero-based and defaults to 0. `remaining` counts matches after the current page; + `next` is `{ offset }` when another page exists and `null` on the final page. +- Search is an internal `Tool.make` definition backed by Effect input/output schemas. Its + validation, output checking, call observation, and TypeScript signature use the same path as + host-provided schema tools. Host-only catalog preparation keeps internal tools out of their + own search index; only conditional advertisement remains special. - Default limit: **10** (done). Exact-path lookup goes through search too: a query equal to a canonical tool path, `tools.`-prefixed path, or rendered JavaScript expression returns that tool alone (done). @@ -251,7 +258,7 @@ output limit; return a smaller value]`; logs keep leading lines within the remai truncation is now the only result-size mechanism.) - **Search polish**: default limit 12 -> **10** (`defaultSearchLimit`); exact-path lookup - a trimmed query equal to one tool path (optionally `tools.`-prefixed) returns that tool alone - (`total: 1`), bypassing ranking. Tokenization/ranking/shape unchanged. + (`remaining: 0`, `next: null`), bypassing ranking. Tokenization/ranking/shape unchanged. ### Wave 3 - OpenCode MCP adapter (done) @@ -312,10 +319,10 @@ Instructions are now the budgeted-catalog + prompting-guidance form; verified e2 real MCP config. Package still 101 tests / 0 fail; opencode adapter suites still 34 + 16; both packages typecheck clean. -- **Budgeted catalog** (`discoveryPlan` in `tool-runtime.ts`): the all-or-nothing +- **Budgeted catalog** (`prepare` in `tool-runtime.ts`): the all-or-nothing inline/search modes are gone - `DiscoveryMode` deleted, `CodeMode.DiscoveryOptions` is just `{ maxInlineCatalogBytes? }` (default 16,000 UTF-8 bytes; later converted to - `maxInlineCatalogTokens`, default 4,000 estimated tokens - see Post-wave fixes). Port of + `catalogBudget`, default 4,000 estimated tokens - see Post-wave fixes). Port of the old opencode `describe()` `PREVIEW_BUDGET` algorithm, adapted to `ToolDescription`: every namespace is ALWAYS listed with its tool count; full signature lines @@ -439,9 +446,9 @@ adapter needed **no changes**. defeating discovery. Fixes, all in this package: - `ToolRuntime.make` now returns a `keys(path)` capability (`namespaceKeys` in `tool-runtime.ts`) threaded into the `Interpreter` alongside `invoke` - the interpreter - still never holds the host tool tree. `Object.keys(tools)` yields the top-level namespace - names (never `$codemode`, which is virtual - but `Object.keys(tools.$codemode)` yields - `["search"]`), `Object.keys(tools.ns)` the names at that node; a callable tool leaf + still never holds the callable tool tree. `Object.keys(tools)` yields the top-level namespace + names, including the internally registered `$codemode`; `Object.keys(tools.$codemode)` yields + `["search"]`, and `Object.keys(tools.ns)` yields names at that node; a callable tool leaf enumerates as `[]` (like `Object.keys` of a JS function); an unknown path throws an `UnknownTool` diagnostic suggesting `Object.keys(tools)` and `$codemode.search` (matching call-time unknown-tool behavior rather than silently returning `[]`). @@ -459,7 +466,7 @@ adapter needed **no changes**. - `supportedSyntaxMessage`, the instructions loops line, and README "Supported Programs" mention the new surface; tests in `test/enumeration.test.ts` (14, incl. the exact transcript program) plus one adapter-level assertion that `Object.keys(tools)` returns - MCP server names. + MCP server and CodeMode namespace names. - **Search ranking, namespace scoping, prefixed result paths (done).** Motivation: the Wave 4 e2e run showed a model retrying calls because search-result paths @@ -480,7 +487,7 @@ adapter needed **no changes**. An empty query now browses ALPHABETICALLY by path (was declaration order). Kept: `{ path, description, signature }` result items, default limit 10, exact-path instant lookup, input validation errors. - - **Namespace scoping**: `tools.$codemode.search({ query?, namespace?, limit? })` - + - **Namespace scoping**: `tools.$codemode.search({ query?, namespace?, limit?, offset? })` - `namespace` (validated as a string when provided) filters `SearchEntry`s to one top-level namespace before ranking; `{ query: "", namespace: "github" }` lists that namespace alphabetically. `searchSignature` updated. @@ -489,7 +496,7 @@ adapter needed **no changes**. segments), directly usable as the call site. Internal `ToolDescription.path` stays unprefixed; only the search RESULT items are rendered this way. Exact-path queries accept canonical paths and rendered expressions. - - **Instructions** (`discoveryPlan`): an explicit calling-convention line and a browse + - **Instructions** (`prepare`): an explicit calling-convention line and a browse hint on the search advertisement (both since absorbed into the `## Rules` section by the instructions restructure below). - **Tests**: package search/discovery tests updated (prefixed paths, alphabetical browse) @@ -499,28 +506,23 @@ adapter needed **no changes**. - **Instructions restructure: markdown sections, placeholder-only call forms (done).** The flat prose instructions (which mixed a real catalog tool with fabricated result - fields in the worked example) are replaced by structured markdown in `discoveryPlan`, + fields in the worked example) are replaced by structured markdown in `prepare`, ordered so the workflow sits at the top (the least likely part of a long description to be truncated or skimmed away) and the catalog at the bottom (the per-section content - described here was later condensed by Fix 8 - Workflow/Rules deduped, Syntax inverted): - - **Intro** (2 lines): "Write a CodeMode program... Return code only." + "Execute - JavaScript in a confined runtime with access to the tools listed below under - `tools.*`." (the second line drops the tools clause when the tree is empty). - - **`## Workflow`**: numbered steps - find a tool via `tools.$codemode.search` -> read - the `{ path, description, signature }` matches -> call by path -> `typeof res === -"string" ? JSON.parse(res) : res` -> return only the needed fields. When the catalog is - COMPLETE the search/read steps collapse into "Pick a tool from the list under - `## Available tools`" and the steps renumber (4 instead of 5). - - **`## Rules`**: call-by-exact-path; TEXT-is-JSON -> JSON.parse; return small (never raw - payloads); filter/aggregate large collections in code instead of per-item round-trips; + described here was later condensed by Fix 8 and the language-accuracy pass): + - **Intro**: identifies the language as restricted JavaScript for calling tools rather than + a general-purpose runtime. + - **`## Workflow`**: with a partial catalog, return search results from one execution, then + copy a selected path into the next execution. With a complete catalog, pick and call an + inlined signature, then return only the needed fields. + - **`## Rules`**: narrow unknown results at runtime; filter/aggregate large collections in code instead of per-item round-trips; console.log/warn/error/dir/table for intermediates; `Promise.all` parallelism (no .then/.catch - await + try/catch); `Object.keys(tools)`/`for...in` enumeration; browse-one-namespace via search (PARTIAL only); and host-side media handling (files/ images never enter the program; a media-only call yields a small text marker - wording verified against the adapter's `toSandboxResult`/`mediaMarker`). - - **`## Syntax`**: the dense syntax lines unchanged, minus the Promise.all and console - lines (moved into Rules) and the `for (const ns in tools)` fragment (redundant with - the enumeration rule). + - **`## Language`**: a concise positive capability summary plus the major unavailable + runtime capabilities and the data-boundary serialization note. - **`## Available tools`**: the budgeted catalog unchanged, with the COMPLETE/PARTIAL header merged into the section heading (no trailing colon); the search-signature advertisement follows when PARTIAL (its description-reading and browse clauses moved @@ -532,7 +534,7 @@ adapter needed **no changes**. appear anywhere in the instructions. Zero tools keep "No tools are currently available." under minimal sections (intro + Syntax + Available tools). - **Tests**: the package worked-example test replaced by section-structure/placeholder - assertions (section order; JSON.parse + return-small rules present; no + assertions (section order; unknown-result + return-small rules present; no `total_count`/`list_issues`/real-tool example lines; browse hint only when PARTIAL; zero-tool minimal sections) - 156 pass / 0 fail; adapter suites gain the same assertions on the built description (still 35 + 16, green). @@ -542,9 +544,9 @@ budget; namespaces must always be present): - `src/token.ts` added: copy of `@opencode-ai/core/util/token` (`round(chars / 4)`), so the package stays dependency-free; keep in sync if the core heuristic changes. -- `CodeMode.DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000 +- `CodeMode.DiscoveryOptions.maxInlineCatalogBytes` -> `catalogBudget` (default 4,000 estimated tokens ~ the old 16,000 bytes at 4 chars/token - behavior parity, not a size - reduction). `discoveryPlan` charges `estimate(catalogLine(tool))` per line; cheapest-first + reduction). `prepare` charges `estimate(catalogLine(tool))` per line; cheapest-first - stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in Fix 8). Namespace stub lines were and remain unbudgeted - every namespace always appears with its tool count, even at budget 0 (asserted in package and @@ -672,10 +674,8 @@ along). All in `tool-runtime.ts`; no interpreter changes. interfaces/type aliases are stripped and TS **enums actually work** (transpileModule compiles them to an IIFE the interpreter runs), hence enums deliberately unmentioned. `supportedSyntaxMessage` (the in-diagnostic text in `codemode.ts`) is untouched. -- **Workflow/Rules deduped**: the call-by-exact-path, JSON.parse-string-results, and - return-small content now lives ONLY in the numbered Workflow steps (with their - compliance-driving justifications inline: "most tools return JSON as a string", "raw - payloads get truncated and waste context"); Rules keeps only bullets adding new +- **Workflow/Rules deduped**: the call-by-exact-path and return-small content now lives ONLY + in the numbered Workflow steps; Rules keeps only bullets adding new content - filter/aggregate collections in code, console.\* intermediates (logs ride back), Promise.all parallelism, Object.keys/for...in enumeration, browse-namespace (PARTIAL only), and the media rule compressed to one line. The no-.then/.catch @@ -690,7 +690,7 @@ along). All in `tool-runtime.ts`; no interpreter changes. (332 -> 176), Syntax 453 -> 188 (419 -> 174); fixed prose total 1,005 -> 610 (927 -> 562), ~ 40% reduction with no behavioral content dropped. Workflow grew slightly because it absorbed the deduped parse/return-small justifications. -- **Round-robin namespace inlining** (`discoveryPlan`): the ported stop-on-first-miss +- **Round-robin namespace inlining** (`prepare`): the ported stop-on-first-miss behavior (alphabetically-late namespaces starved to "none shown" while an early namespace inlines everything) is replaced by round-robin fairness - in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to @@ -708,9 +708,9 @@ along). All in `tool-runtime.ts`; no interpreter changes. the four field checks passes when ANY form matches. Weights, exact-path lookup, and namespace scoping untouched. A true plural path match still outranks a singular-only description match (path substring 8 + searchable 2 > description 4 + searchable 2). -- **Tests**: package instruction/structure assertions updated to the new text; new - syntax-section test (leads with "Standard modern JavaScript works", names the - verified not-supported list, keeps the data-boundary note); the budget-exhaustion +- **Tests**: package instruction/structure assertions updated to the new text; the + language-section test rejects full-runtime wording, names major unavailable capabilities, + and keeps the data-boundary note; the budget-exhaustion test rewritten to assert the new fairness (alpha.expensive not fitting must NOT prevent beta.cheap from showing: PARTIAL 2 of 3, `- beta (1 tool)` fully shown); new plural/singular test (query "issues" finds a singular-only tool; ranking still @@ -724,7 +724,7 @@ along). All in `tool-runtime.ts`; no interpreter changes. **Fix 9 - prompting trims per user review of Fix 8** (user reviewed the condensed instructions and directed further cuts): -- Default `maxInlineCatalogTokens` 4,000 -> **2,000** (user wants ~2k tokens of signatures +- Default `catalogBudget` 4,000 -> **2,000** (user wants ~2k tokens of signatures auto-inlined; round-robin fairness from Fix 8 spreads it across all namespaces). - Console rule and files/images rule DROPPED from `## Rules`. Replaced by a single `unknown`-treatment warning: "A result typed `Promise` has no guaranteed @@ -733,9 +733,9 @@ instructions and directed further cuts): return the same data; the prompt stays console-neutral, neither for nor against.) The media-stripping MECHANISM is unchanged and still tested; only the prose about it is gone - the `[N images attached]` marker is self-explanatory in context. -- Kept as-is per user: the JSON.parse workflow step (maps to the original motivating - transcript failure; NOT copied from prior art - see section 5 note), the browse-namespace rule - (undecided), no no-fetch/ambient-authority rule added (proposed, not approved). +- Later revised: unconditional JSON parsing was removed because text results are not + necessarily JSON. The browse-namespace rule remains; the language section now states that + ambient `fetch` is unavailable and external operations go through Code Mode tools. - Explicitly REJECTED for now: auto-parsing JSON-looking text results at the adapter boundary ("could get weird" - type flips, program-sees vs tool-sent divergence). Logged as a next-iteration follow-up below. @@ -1004,11 +1004,19 @@ focused interpreter-surface pass rather than picked off piecemeal. - [x] Sandbox values nested inside logged containers print `[CodeMode reference]` (`console.log({ m: map })`) - could deep-format instead. +### Next iteration: optional search input boundary + +- [ ] `SearchInput` uses Effect's exact `optionalKey`, so an omitted field is accepted but an + explicitly present `undefined` field is rejected. The previous handwritten validator + treated explicit `undefined` as omission. Decide whether search should preserve that + convenience locally or whether all tool arguments should adopt JSON-style undefined + normalization; do not broaden `copyOut` semantics solely to fix search. + ### Next iteration: text-result handling (deliberate follow-up, user-directed) - [ ] Revisit how MCP text results reach the program. Today: `structuredContent` when the - server sends it, else joined text as a plain string (the program JSON.parses it, - guided by a workflow step). Considered and deferred: (a) conservative boundary + server sends it, else joined text as a plain string. Programs narrow unknown results + before use; the prompt no longer recommends unconditional JSON parsing. Considered and deferred: (a) conservative boundary auto-parse (text starting with `{`/`[` that parses cleanly becomes an object) - rejected for now as potentially confusing (type flips; program sees something other than what the tool sent); (b) raw-envelope passthrough with the envelope shape diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index b207efe46a8c..083f62e28b54 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -38,12 +38,12 @@ export type ExecutionLimits = { /** Controls how much of the tool catalog is inlined in agent instructions. */ export type DiscoveryOptions = { /** - * Estimated-token budget (chars/4, default 2000) for inlined full tool signatures in agent - * instructions. Signatures that fit are inlined round-robin across namespaces; every - * namespace is always listed with its tool count regardless of budget, and + * Approximate budget, in estimated tokens (chars/4, default 2000), for full tool entries in + * the instruction catalog. Tool entries are selected round-robin across namespaces. Fixed + * instructions and namespace summaries do not count toward the budget, and * `tools.$codemode.search` is always registered. */ - readonly maxInlineCatalogTokens?: number + readonly catalogBudget?: number } type ToolTree = { @@ -3921,8 +3921,8 @@ const executeWithLimits = >( const tools = ToolRuntime.make( (options.tools ?? {}) as HostTools>, limits.maxToolCalls, - hooks, searchIndex, + hooks, ) const logs: Array = [] const logged = () => (logs.length > 0 ? { logs: [...logs] } : {}) @@ -4061,10 +4061,10 @@ export const make = = {}>( const tools = (options.tools ?? {}) as HostTools> ToolRuntime.assertValidTools(tools) const limits = resolveExecutionLimits(options.limits) - const discovery = ToolRuntime.discoveryPlan(tools, options.discovery?.maxInlineCatalogTokens) - const executeProgram = (code: string) => executeWithLimits({ ...options, code }, limits, discovery.searchIndex) - const catalog = discovery.catalog - const instructions = discovery.instructions + const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget) + const executeProgram = (code: string) => executeWithLimits({ ...options, code }, limits, prepared.searchIndex) + const catalog = prepared.catalog + const instructions = prepared.instructions return { catalog: () => catalog, diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index f19e7a9b4d55..29b27cb5c6ca 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -1,4 +1,4 @@ -import { Cause, Effect } from "effect" +import { Cause, Effect, Schema } from "effect" import { ToolError, toolError } from "./tool-error.js" import { decodeInput as decodeToolInput, @@ -8,6 +8,7 @@ import { inputTypeScript, isDefinition as isToolDefinition, outputTypeScript, + Tool, type Definition, } from "./tool.js" import { SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js" @@ -76,10 +77,26 @@ export type ToolDescription = { export type SafeObject = Record const reservedNamespace = "$codemode" -const defaultMaxInlineCatalogTokens = 2_000 +const defaultCatalogBudget = 2_000 const defaultSearchLimit = 10 -const searchSignature = - "tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>" +const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) +const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) +const SearchInput = Schema.Struct({ + query: Schema.optionalKey(Schema.String), + namespace: Schema.optionalKey(Schema.String), + limit: Schema.optionalKey(PositiveInt), + offset: Schema.optionalKey(NonNegativeInt), +}) +const SearchItem = Schema.Struct({ + path: Schema.String, + description: Schema.String, + signature: Schema.String, +}) +const SearchOutput = Schema.Struct({ + items: Schema.Array(SearchItem), + remaining: NonNegativeInt, + next: Schema.NullOr(Schema.Struct({ offset: NonNegativeInt })), +}) const toolExpression = (path: string) => "tools" + path @@ -295,15 +312,17 @@ const definitions = ( return entries } +const describeDefinition = (path: string, definition: Definition): ToolDescription => ({ + path, + description: definition.description, + signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`, +}) + const visibleDefinitions = (tools: HostTools) => definitions(tools).map(({ path, definition }) => ({ path, definition, - description: { - path, - description: definition.description, - signature: `${toolExpression(path)}(input: ${inputTypeScript(definition)}): Promise<${outputTypeScript(definition)}>`, - }, + description: describeDefinition(path, definition), })) export const catalog = (tools: HostTools): ReadonlyArray => @@ -317,11 +336,6 @@ export type DiscoveryPlan = { export type SearchEntry = { readonly description: ToolDescription - /** - * JSDoc-annotated multiline signature shown on search-result items; the compact - * single-line form (inline catalog lines) stays in `description.signature`. - */ - readonly signature: string /** Top-level namespace (first path segment), matched by the search `namespace` option. */ readonly namespace: string /** Lowercased path + description + input property names/descriptions, for substring matching. */ @@ -355,8 +369,76 @@ const termForms = (term: string): Array => { return forms } +const makeSearchTool = (searchIndex: ReadonlyArray) => + Tool.make({ + description: "Search available Code Mode tools", + input: SearchInput, + output: SearchOutput, + run: (request) => + Effect.sync(() => { + const query = request.query ?? "" + const offset = request.offset ?? 0 + const scoped = + request.namespace === undefined + ? searchIndex + : searchIndex.filter((entry) => entry.namespace === request.namespace) + // A query that names one tool path exactly (canonical path or rendered JavaScript + // expression) is a lookup, not a search: return that tool alone. + const trimmed = query.trim() + const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed + const exact = + pathQuery === "" + ? undefined + : scoped.find( + (entry) => + entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed, + ) + const terms = tokenize(query).map(termForms) + // Additive field-weighted scoring, summed across terms: exact path or path segment + // (20) > path substring (8) > description substring (4) > any searchable text, + // including input parameter names and descriptions (2). + const ranked = + exact !== undefined + ? [exact] + : scoped + .map((entry) => { + const path = entry.description.path.toLowerCase() + const description = entry.description.description.toLowerCase() + const score = terms.reduce( + (total, forms) => + total + + (forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) + + (forms.some((form) => path.includes(form)) ? 8 : 0) + + (forms.some((form) => description.includes(form)) ? 4 : 0) + + (forms.some((form) => entry.searchText.includes(form)) ? 2 : 0), + 0, + ) + return { entry, score } + }) + .filter(({ score }) => terms.length === 0 || score > 0) + .sort( + (left, right) => + right.score - left.score || + left.entry.description.path.localeCompare(right.entry.description.path), + ) + .map(({ entry }) => entry) + const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({ + ...description, + path: toolExpression(description.path), + })) + const remaining = Math.max(0, ranked.length - offset - items.length) + return { + items, + remaining, + next: remaining > 0 ? { offset: offset + items.length } : null, + } + }), + }) + +const searchDescription = describeDefinition(`${reservedNamespace}.search`, makeSearchTool([])) + const catalogLine = (tool: ToolDescription) => { - // Inline catalog lines use only a compact first line; full text stays in search results. + // Keep the tool description concise; the full schema documentation remains in the signature. const line = tool.description.split("\n", 1)[0]!.trim() const description = line.length > 120 ? line.slice(0, 119) + "..." : line return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}` @@ -364,7 +446,6 @@ const catalogLine = (tool: ToolDescription) => { const toSearchEntry = (path: string, definition: Definition, description: ToolDescription): SearchEntry => ({ description, - signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`, namespace: path.split(".", 1)[0]!, searchText: [ path, @@ -389,7 +470,7 @@ export const assertValidTools = (tools: HostTools): void => { /** * Budgeted catalog: every namespace is always listed with its tool count; full call - * signatures are inlined against the `maxInlineCatalogTokens` budget (estimated tokens, + * signatures are inlined against the `catalogBudget` (estimated tokens, * chars/4) round-robin across namespaces - in each round (namespaces alphabetical), every * namespace still holding un-inlined tools attempts to place its next-cheapest line, and * a namespace whose next line does not fit is done while the others keep going - so every @@ -398,12 +479,12 @@ export const assertValidTools = (tools: HostTools): void => { * namespace. Namespace stub lines are never budgeted: every namespace appears with its * tool count even at budget 0. */ -export const discoveryPlan = ( +export const prepare = ( tools: HostTools, - maxInlineCatalogTokens = defaultMaxInlineCatalogTokens, + catalogBudget = defaultCatalogBudget, ): DiscoveryPlan => { - if (!Number.isSafeInteger(maxInlineCatalogTokens) || maxInlineCatalogTokens < 0) { - throw new RangeError("discovery.maxInlineCatalogTokens must be a non-negative safe integer") + if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) { + throw new RangeError("discovery.catalogBudget must be a non-negative safe integer") } const visible = visibleDefinitions(tools) const described = visible.map(({ description }) => description) @@ -438,7 +519,7 @@ export const discoveryPlan = ( for (const selection of active) { const tool = selection.queue[0]! const cost = estimateTokens(catalogLine(tool)) - if (used + cost > maxInlineCatalogTokens) continue + if (used + cost > catalogBudget) continue selection.queue.shift() selection.picked.add(tool) used += cost @@ -459,12 +540,11 @@ export const discoveryPlan = ( // catalog at the bottom. Example call forms use placeholders - never a real or fabricated // tool name - and show both dot and bracket notation so non-identifier names are not normalized. const intro = [ - "Write a CodeMode program to answer the request. Return code only.", empty - ? "Execute JavaScript in a confined runtime." + ? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime." : complete - ? "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed below; surrounding agent tools are not available unless listed here." - : "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed or searchable below; surrounding agent tools are not available unless listed here.", + ? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed below and internal runtime tools; surrounding agent tools are not available." + : "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed or searchable below and internal runtime tools; surrounding agent tools are not available.", ...(empty ? [] : ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]), @@ -481,16 +561,12 @@ export const discoveryPlan = ( ...(complete ? [ "1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.", - "2. Call it using the exact signature shown; bracket notation and quotes are part of the path.", - '3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.', - "4. Return only the fields you need: `return { : data. }` - raw payloads get truncated and waste context.", + "2. Call it using the exact signature shown: `const result = await tools..(input)`; bracket notation and quotes are part of the path.", + "3. Return only the fields you need from structured results; narrow unknown results before reading fields, and avoid returning large raw payloads.", ] : [ - '1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "" })`.', - "2. Read the matches: each item is `{ path, description, signature }` - read the description before using an unfamiliar tool.", - "3. Call the result's `path` as-is; bracket notation and quotes are part of the path.", - '4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.', - "5. Return only the fields you need: `return { : data. }` - raw payloads get truncated and waste context.", + '1. If needed, discover tools: `return await tools.$codemode.search({ query: "" })`.', + "2. In the next execution, copy a returned path exactly, call it, and return only the needed fields.", ]), ] @@ -501,24 +577,26 @@ export const discoveryPlan = ( "## Rules", "", complete - ? "- Only tools listed here are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed." - : "- Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed.", + ? "- Only Code Mode tools listed here and internal runtime tools are available; surrounding agent tools are not implicitly exposed." + : "- Only Code Mode tools listed here or returned by `tools.$codemode.search` and internal runtime tools are available; surrounding agent tools are not implicitly exposed.", "- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.", - "- A result typed `Promise` has no guaranteed shape - verify what actually came back before relying on its fields.", + "- A result typed `Promise` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.", '- Run independent calls in parallel: `await Promise.all(items.map((item) => tools..(item)))`, or use `tools.["tool-name"](item)` when the listed signature uses bracket notation.', "- `Object.keys(tools)` lists namespaces; `Object.keys(tools.)` lists its tools; `for...in` works on both.", ...(complete ? [] - : ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.']), + : [ + '- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.', + "- If search returns `next`, repeat the same search with `offset: next.offset`.", + ]), ] - const syntax = [ + const language = [ "", - "## Syntax", + "## Language", "", - "Standard modern JavaScript works: functions/closures, destructuring, template literals, loops, try/catch, spread, optional chaining, the usual Array/String/Object/Math/JSON methods, plus Date, RegExp, Map, Set, and Promise.all/allSettled/race/resolve/reject.", - "TypeScript type annotations are allowed and stripped before execution (decorators are not supported).", - "Not supported (each fails with a message naming the alternative): classes, generators, for await...of, .then/.catch/.finally (use await with try/catch).", + "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls.", + "Modules/imports, classes, generators, timers, fetch, eval, prototype access, arbitrary methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.", "Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.", ] @@ -547,11 +625,11 @@ export const discoveryPlan = ( for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool)) } if (!complete) { - toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`) + toolSection.push("", "Search returns complete callable signatures:", `- ${searchDescription.signature}`) } } - const lines = [...intro, ...workflow, ...rules, ...syntax, ...toolSection] + const lines = [...intro, ...workflow, ...rules, ...language, ...toolSection] return { catalog: described, instructions: lines.join("\n"), @@ -560,7 +638,7 @@ export const discoveryPlan = ( } /** - * The enumerable names at one node of the host tool tree - namespace names at the root, + * The enumerable names at one node of the callable tool tree - namespace names at the root, * tool/namespace names below - powering `Object.keys(tools)` and `for...in` over tool * references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a * function in JS). An unknown path is an `UnknownTool` error pointing at the working @@ -569,11 +647,7 @@ export const discoveryPlan = ( const namespaceKeys = ( tools: HostTools, path: ReadonlyArray, - searchEnabled: boolean, ): ReadonlyArray => { - // The reserved discovery namespace is virtual (never present in the host tree); enumerate - // it explicitly so `Object.keys(tools.$codemode)` matches the callable surface. - if (searchEnabled && path.length === 1 && path[0] === reservedNamespace) return ["search"] let value: HostTool | Definition | HostTools = tools for (const segment of path) { if ( @@ -585,11 +659,9 @@ const namespaceKeys = ( throw new ToolRuntimeError( "UnknownTool", `Unknown tool namespace '${path.join(".")}'.`, - searchEnabled - ? [ - "Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.", - ] - : ["Object.keys(tools) lists the available namespaces."], + [ + "Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.", + ], ) } value = value[segment] as HostTool | Definition | HostTools @@ -601,7 +673,6 @@ const namespaceKeys = ( const resolve = ( tools: HostTools, path: ReadonlyArray, - searchEnabled: boolean, ): HostTool | Definition => { let value: HostTool | Definition | HostTools = tools @@ -615,7 +686,7 @@ const resolve = ( throw new ToolRuntimeError( "UnknownTool", `Unknown tool '${path.join(".")}'.`, - searchEnabled ? ["Use tools.$codemode.search({ query }) to find available described tools."] : [], + ["Use tools.$codemode.search({ query }) to find available described tools."], ) } value = value[segment] as HostTool | Definition | HostTools @@ -632,7 +703,7 @@ export type ToolRuntime = { readonly root: ToolReference readonly calls: Array readonly invoke: (path: ReadonlyArray, args: Array) => Effect.Effect - /** Enumerable namespace/tool names at one node of the host tool tree; see `namespaceKeys`. */ + /** Enumerable namespace/tool names at one node of the callable tool tree; see `namespaceKeys`. */ readonly keys: (path: ReadonlyArray) => ReadonlyArray } @@ -640,11 +711,14 @@ export const make = ( tools: HostTools, /** Undefined means unlimited tool calls. */ maxToolCalls: number | undefined, + searchIndex: ReadonlyArray, hooks?: ToolCallHooks, - searchIndex?: ReadonlyArray, ): ToolRuntime => { const calls: Array = [] - const searchEnabled = searchIndex !== undefined + const callableTools = { + ...tools, + [reservedNamespace]: { search: makeSearchTool(searchIndex) }, + } // Wraps the settling portion of a tool call so onToolCallEnd observes success and failure // symmetrically. Interruption (e.g. the execution timeout) fires neither outcome. @@ -683,7 +757,7 @@ export const make = ( return { root: new ToolReference([]), calls, - keys: (path) => namespaceKeys(tools, path, searchEnabled), + keys: (path) => namespaceKeys(callableTools, path), invoke: (path, args) => Effect.gen(function* () { const name = path.join(".") @@ -694,107 +768,7 @@ export const make = ( recordCall(call) return calls.length - 1 }).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void)) - if (name === "$codemode.search") { - if (!searchEnabled) throw new ToolRuntimeError("UnknownTool", `Unknown tool '${name}'.`) - const input = externalArgs[0] - if (externalArgs.length !== 1 || input === null || typeof input !== "object" || Array.isArray(input)) { - throw new ToolRuntimeError( - "InvalidToolInput", - "tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.", - ) - } - const request = input as { query?: unknown; namespace?: unknown; limit?: unknown } - if (request.query !== undefined && typeof request.query !== "string") { - throw new ToolRuntimeError( - "InvalidToolInput", - "tools.$codemode.search query must be a string when provided.", - ) - } - if (request.namespace !== undefined && typeof request.namespace !== "string") { - throw new ToolRuntimeError( - "InvalidToolInput", - "tools.$codemode.search namespace must be a string when provided.", - ) - } - if ( - request.limit !== undefined && - (typeof request.limit !== "number" || !Number.isSafeInteger(request.limit) || request.limit <= 0) - ) { - throw new ToolRuntimeError( - "InvalidToolInput", - "tools.$codemode.search limit must be a positive safe integer when provided.", - ) - } - const query = typeof request.query === "string" ? request.query : "" - const namespace = typeof request.namespace === "string" ? request.namespace : undefined - const index = yield* recordAndObserve(request) - return yield* observeEnd( - Effect.try({ - try: () => { - const limit = typeof request.limit === "number" ? request.limit : defaultSearchLimit - const scoped = - namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace) - // A query that names one tool path exactly (canonical path or rendered - // JavaScript expression) is a lookup, not a search: return that tool alone. - const trimmed = query.trim() - const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed - const exact = - pathQuery === "" - ? undefined - : scoped.find( - (entry) => - entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed, - ) - const terms = tokenize(query).map(termForms) - // Additive field-weighted scoring, summed across terms: exact path or path - // segment (20) > path substring (8) > description substring (4) > any - // searchable text, incl. input parameter names/descriptions (2). Each term - // matches a field when any of its forms (the term or a singular variant) - // does. An empty query browses everything, alphabetical by path. - const ranked = - exact !== undefined - ? [exact] - : scoped - .map((entry) => { - const path = entry.description.path.toLowerCase() - const description = entry.description.description.toLowerCase() - const score = terms.reduce( - (total, forms) => - total + - (forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) + - (forms.some((form) => path.includes(form)) ? 8 : 0) + - (forms.some((form) => description.includes(form)) ? 4 : 0) + - (forms.some((form) => entry.searchText.includes(form)) ? 2 : 0), - 0, - ) - return { entry, score } - }) - .filter(({ score }) => terms.length === 0 || score > 0) - .sort( - (left, right) => - right.score - left.score || - left.entry.description.path.localeCompare(right.entry.description.path), - ) - .map(({ entry }) => entry) - // Result paths are rendered as JavaScript expressions so each `path` is - // directly usable as the call site (`await tools.github.list({ ... })` or - // `await tools.ns["dashed-name"]({ ... })`). The signature is the pretty, - // JSDoc-annotated form (schema descriptions and constraints ride along as - // field comments). - const items = ranked.slice(0, limit).map(({ description, signature }) => ({ - ...description, - path: toolExpression(description.path), - signature, - })) - return copyIn({ items, total: ranked.length }, "Result from tool '$codemode.search'") - }, - catch: (cause) => cause, - }), - { index, name, input: request }, - ) - } - - const tool = resolve(tools, path, searchEnabled) + const tool = resolve(callableTools, path) let describedInput: unknown if (isDefinition(tool)) { if (externalArgs.length !== 1) diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index e89f3d39d6c5..4ed2ecbcf29e 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -244,9 +244,9 @@ const renderSchema = ( if (properties.length === 0 && indexType === undefined) return "{}" const pad = " ".repeat(depth + 1) const lines = properties.map( - (entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`, + (entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)},`, ) - if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`) + if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType},`) return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}` } return "unknown" diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 087678c778cb..168dc93d5e26 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -426,7 +426,7 @@ describe("CodeMode schema flexibility", () => { { path: "adapter.call", description: "Call an adapter-described tool", - signature: "tools.adapter.call(input: { id: string; count?: number }): Promise", + signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise", }, ]) @@ -459,7 +459,7 @@ describe("CodeMode schema flexibility", () => { { path: "users.lookup", description: "Look up a user", - signature: "tools.users.lookup(input: { login: string }): Promise<{ login: string; id: number }>", + signature: "tools.users.lookup(input: {\n login: string,\n}): Promise<{\n login: string,\n id: number,\n}>", }, ]) @@ -475,7 +475,7 @@ describe("CodeMode schema flexibility", () => { run: () => Effect.succeed("pong"), }) const runtime = CodeMode.make({ tools: { net: { ping } } }) - expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: { host: string }): Promise") + expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise") const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`)) expect(result.ok).toBe(true) @@ -512,19 +512,19 @@ describe("CodeMode public contract", () => { { path: "orders.lookup", description: "Look up an order by ID", - signature: "tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }>", + signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>", }, ]) expect(runtime.instructions()).toContain("Available tools (COMPLETE list") expect(runtime.instructions()).toContain("- orders (1 tool)") expect(runtime.instructions()).toContain( - " - tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }> // Look up an order by ID", + " - tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}> // Look up an order by ID", ) // A fully inlined catalog does not advertise search in the instructions... expect(runtime.instructions()).not.toMatch(/\$codemode/) - // ...but the search tool stays registered, so a speculative call still works. Search - // results carry the pretty multiline signature; the inline catalog stays compact. + // ...but the search tool stays registered, so a speculative call still works with the + // same signature as the inline catalog. const result = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "order" })`)) expect(result.ok).toBe(true) if (result.ok) { @@ -533,10 +533,11 @@ describe("CodeMode public contract", () => { { path: "tools.orders.lookup", description: "Look up an order by ID", - signature: "tools.orders.lookup(input: {\n id: string\n}): Promise<{\n id: string\n status: string\n}>", + signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>", }, ], - total: 1, + remaining: 0, + next: null, }) } }) @@ -554,11 +555,11 @@ describe("CodeMode public contract", () => { { path: "context7.resolve-library-id", description: "Resolve a library ID", - signature: 'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise', + signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise', }, ]) expect(runtime.instructions()).toContain( - 'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise', + 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise', ) const search = await Effect.runPromise( @@ -571,10 +572,11 @@ describe("CodeMode public contract", () => { { path: 'tools.context7["resolve-library-id"]', description: "Resolve a library ID", - signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string\n}): Promise', + signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise', }, ], - total: 1, + remaining: 0, + next: null, }) } @@ -588,7 +590,7 @@ describe("CodeMode public contract", () => { runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`), ) expect(exact.ok).toBe(true) - if (exact.ok) expect((exact.value as { total: number }).total).toBe(1) + if (exact.ok) expect(exact.value).toMatchObject({ remaining: 0, next: null }) }) test("instructions use markdown sections with placeholder-only call forms", () => { @@ -597,23 +599,26 @@ describe("CodeMode public contract", () => { // Sections in order: workflow at the top, catalog at the bottom. expect(instructions).toContain("## Workflow") expect(instructions).toContain("## Rules") - expect(instructions).toContain("## Syntax") + expect(instructions).toContain("## Language") expect(instructions.indexOf("## Workflow")).toBeLessThan(instructions.indexOf("## Rules")) - expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Syntax")) - expect(instructions.indexOf("## Syntax")).toBeLessThan(instructions.indexOf("\n## Available tools (COMPLETE list")) - // The workflow carries the result-shape guidance; Rules only add content beyond it. - expect(instructions).toContain( - '`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string', + expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Language")) + expect(instructions.indexOf("## Language")).toBeLessThan( + instructions.indexOf("\n## Available tools (COMPLETE list"), ) + expect(instructions).not.toContain("JSON.parse(res)") expect(instructions).toContain("Return only the fields you need") - expect(instructions).toContain("raw payloads get truncated and waste context") + expect(instructions).toContain("avoid returning large raw payloads") expect(instructions).toContain("Do not infer or normalize tool names") expect(instructions).toContain("bracket notation and quotes are part of the path") - expect(instructions).toContain("surrounding agent tools are not available unless listed here") - expect(instructions).toContain("Only tools listed here are available inside `tools`") + expect(instructions).toContain("surrounding agent tools are not available") + expect(instructions).toContain("Only Code Mode tools listed here and internal runtime tools") // Placeholders use generic namespace/tool/field names only - no fabricated real tools // and no real catalog tools cherry-picked into example lines. - expect(instructions).toContain("`return { : data. }`") + expect(instructions).toContain("`const result = await tools..(input)`") + expect(instructions).toContain("Return only the fields you need from structured results") + expect(instructions).toContain("check that it is a non-null object and not an array") + expect(instructions).not.toContain("result.") + expect(instructions).not.toContain("data.") expect(instructions).not.toContain("total_count") expect(instructions).not.toContain("list_issues") expect(instructions).not.toContain("tools.orders.lookup({") @@ -621,36 +626,35 @@ describe("CodeMode public contract", () => { expect(instructions).toContain("1. Pick a tool from the list under `## Available tools`") expect(instructions).not.toContain("Browse one namespace") - const partial = CodeMode.make({ tools, discovery: { maxInlineCatalogTokens: 0 } }).instructions() + const partial = CodeMode.make({ tools, discovery: { catalogBudget: 0 } }).instructions() // PARTIAL: the workflow starts with search (with query-style guidance that is clearly // a query string, never a tool name) and the browse-namespace rule appears. expect(partial).toContain( - '1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "" })`.', + '1. If needed, discover tools: `return await tools.$codemode.search({ query: "" })`.', ) + expect(partial).toContain("In the next execution, copy a returned path exactly") expect(partial).toContain( - "Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`", + "Only Code Mode tools listed here or returned by `tools.$codemode.search` and internal runtime tools", ) expect(partial).toContain( '- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.', ) + expect(partial).toContain("repeat the same search with `offset: next.offset`") + expect(partial).toContain(" limit?: number,\n offset?: number,") expect(partial).not.toContain("total_count") expect(partial).not.toContain("tools.orders.lookup({") }) - test("the syntax section names what is unusual or missing, not an allowlist", () => { + test("the language section describes the restricted runtime without overclaiming", () => { const instructions = CodeMode.make({ tools }).instructions() - // Models already know JavaScript; the section leads with that. - expect(instructions).toContain("Standard modern JavaScript works") - expect(instructions).toContain("TypeScript type annotations are allowed and stripped before execution") - // The not-supported list is derived from (and verified against) the interpreter. - expect(instructions).toContain("Not supported") - for (const missing of ["classes", "generators", "for await...of", ".then/.catch/.finally"]) { + expect(instructions).toContain("restricted JavaScript language for calling tools") + expect(instructions).toContain("not a general-purpose runtime") + expect(instructions).not.toContain("Standard modern JavaScript works") + expect(instructions).not.toContain("TypeScript type annotations") + for (const missing of ["Modules/imports", "classes", "generators", "fetch", "promise chaining"]) { expect(instructions).toContain(missing) } - // Implemented by the DSL-expansion pass, so no longer listed as missing. - expect(instructions).not.toContain("instanceof Error") - expect(instructions).not.toContain("splice") - // The data-boundary note survives. + expect(instructions).toContain("Use Code Mode tools for external operations") expect(instructions).toContain( "Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.", ) @@ -660,7 +664,7 @@ describe("CodeMode public contract", () => { const runtime = CodeMode.make({}) const instructions = runtime.instructions() expect(instructions).toContain("No tools are currently available.") - expect(instructions).toContain("## Syntax") + expect(instructions).toContain("## Language") expect(instructions).toContain("## Available tools") expect(instructions).not.toContain("## Workflow") expect(instructions).not.toContain("## Rules") @@ -682,7 +686,7 @@ describe("CodeMode public contract", () => { }) const runtime = CodeMode.make({ tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } }, - discovery: { maxInlineCatalogTokens: 0 }, + discovery: { catalogBudget: 0 }, }) expect(runtime.instructions()).toContain( "Available tools (PARTIAL - 0 of 3 shown; find the rest with tools.$codemode.search)", @@ -707,15 +711,16 @@ describe("CodeMode public contract", () => { { path: "tools.thread.uploadFile", description: "Upload one readable local file to the current Discord thread", - signature: "tools.thread.uploadFile(input: {\n path: string\n}): Promise<{\n sent: boolean\n}>", + signature: "tools.thread.uploadFile(input: {\n path: string,\n}): Promise<{\n sent: boolean,\n}>", }, { path: "tools.thread.generateImage", description: "Generate an image and upload it to the current Discord thread", - signature: "tools.thread.generateImage(input: {\n prompt: string\n}): Promise<{\n sent: boolean\n}>", + signature: "tools.thread.generateImage(input: {\n prompt: string,\n}): Promise<{\n sent: boolean,\n}>", }, ], - total: 2, + remaining: 0, + next: null, }) expect(result.toolCalls).toStrictEqual([{ name: "$codemode.search" }]) @@ -761,9 +766,14 @@ describe("CodeMode public contract", () => { const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`)) expect(browse.ok).toBe(true) if (browse.ok) { - const value = browse.value as { items: Array<{ path: string }>; total: number } + const value = browse.value as { + items: Array<{ path: string }> + remaining: number + next: { offset: number } | null + } expect(value.items).toHaveLength(10) - expect(value.total).toBe(14) + expect(value.remaining).toBe(4) + expect(value.next).toStrictEqual({ offset: 10 }) } for (const query of ["many.tool13", "tools.many.tool13"]) { @@ -777,10 +787,11 @@ describe("CodeMode public contract", () => { { path: "tools.many.tool13", description: "Numbered tool 13", - signature: "tools.many.tool13(input: {\n id: string\n}): Promise", + signature: "tools.many.tool13(input: {\n id: string,\n}): Promise", }, ], - total: 1, + remaining: 0, + next: null, }) } } @@ -807,8 +818,8 @@ describe("CodeMode public contract", () => { ) expect(browse.ok).toBe(true) if (browse.ok) { - const value = browse.value as { items: Array<{ path: string }>; total: number } - expect(value.total).toBe(2) + const value = browse.value as { items: Array<{ path: string }>; remaining: number } + expect(value.remaining).toBe(0) expect(value.items.map((item) => item.path)).toStrictEqual([ "tools.github.create_issue", "tools.github.list_issues", @@ -821,8 +832,8 @@ describe("CodeMode public contract", () => { ) expect(scoped.ok).toBe(true) if (scoped.ok) { - const value = scoped.value as { items: Array<{ path: string }>; total: number } - expect(value.total).toBe(1) + const value = scoped.value as { items: Array<{ path: string }>; remaining: number } + expect(value.remaining).toBe(0) expect(value.items[0]?.path).toBe("tools.linear.list_issues") } @@ -858,8 +869,8 @@ describe("CodeMode public contract", () => { ) expect(byParameter.ok).toBe(true) if (byParameter.ok) { - const value = byParameter.value as { items: Array<{ path: string }>; total: number } - expect(value.total).toBe(1) + const value = byParameter.value as { items: Array<{ path: string }>; remaining: number } + expect(value.remaining).toBe(0) expect(value.items[0]?.path).toBe("tools.files.upload") } @@ -869,8 +880,8 @@ describe("CodeMode public contract", () => { ) expect(bySubstring.ok).toBe(true) if (bySubstring.ok) { - const value = bySubstring.value as { items: Array<{ path: string }>; total: number } - expect(value.total).toBe(1) + const value = bySubstring.value as { items: Array<{ path: string }>; remaining: number } + expect(value.remaining).toBe(0) expect(value.items[0]?.path).toBe("tools.files.upload") } }) @@ -898,8 +909,8 @@ describe("CodeMode public contract", () => { ) expect(plural.ok).toBe(true) if (plural.ok) { - const value = plural.value as { items: Array<{ path: string }>; total: number } - expect(value.total).toBe(1) + const value = plural.value as { items: Array<{ path: string }>; remaining: number } + expect(value.remaining).toBe(0) expect(value.items[0]?.path).toBe("tools.tracker.fetch_all") } @@ -907,8 +918,8 @@ describe("CodeMode public contract", () => { const ranked = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "issues" })`)) expect(ranked.ok).toBe(true) if (ranked.ok) { - const value = ranked.value as { items: Array<{ path: string }>; total: number } - expect(value.total).toBe(2) + const value = ranked.value as { items: Array<{ path: string }>; remaining: number } + expect(value.remaining).toBe(0) expect(value.items.map((item) => item.path)).toStrictEqual([ "tools.github.list_issues", "tools.tracker.fetch_all", @@ -934,13 +945,33 @@ describe("CodeMode public contract", () => { const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`)) expect(browse.ok).toBe(true) if (browse.ok) { - const value = browse.value as { items: Array<{ path: string }>; total: number } + const value = browse.value as { items: Array<{ path: string }>; remaining: number; next: unknown } expect(value.items.map((item) => item.path)).toStrictEqual([ "tools.alpha.aardvark", "tools.alpha.beta", "tools.zeta.last", ]) + expect(value.remaining).toBe(0) + expect(value.next).toBeNull() + } + + const middle = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ limit: 1, offset: 1 })`), + ) + expect(middle.ok).toBe(true) + if (middle.ok) { + expect(middle.value).toMatchObject({ + items: [{ path: "tools.alpha.beta" }], + remaining: 1, + next: { offset: 2 }, + }) } + + const exhausted = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ limit: 1, offset: 3 })`), + ) + expect(exhausted.ok).toBe(true) + if (exhausted.ok) expect(exhausted.value).toStrictEqual({ items: [], remaining: 0, next: null }) }) test("inlines round-robin across namespaces so one expensive namespace cannot starve the rest", () => { @@ -965,7 +996,7 @@ describe("CodeMode public contract", () => { // other namespaces from inlining (beta already got its line in the same round). const runtime = CodeMode.make({ tools: { alpha: { cheap, expensive }, beta: { cheap } }, - discovery: { maxInlineCatalogTokens: 40 }, + discovery: { catalogBudget: 40 }, }) const instructions = runtime.instructions() @@ -973,14 +1004,38 @@ describe("CodeMode public contract", () => { "Available tools (PARTIAL - 2 of 3 shown; find the rest with tools.$codemode.search)", ) expect(instructions).toContain("- alpha (2 tools, 1 shown)") - expect(instructions).toContain(" - tools.alpha.cheap(input: { q: string }): Promise // Cheap") + expect(instructions).toContain(" - tools.alpha.cheap(input: {\n q: string,\n}): Promise // Cheap") expect(instructions).not.toContain("tools.alpha.expensive(") // Fully shown namespaces read cleanly (no "shown" annotation). expect(instructions).toContain("- beta (1 tool)") - expect(instructions).toContain(" - tools.beta.cheap(input: { q: string }): Promise // Cheap") + expect(instructions).toContain(" - tools.beta.cheap(input: {\n q: string,\n}): Promise // Cheap") expect(instructions).toMatch(/\$codemode\.search/) }) + test("charges inline JSDoc against the catalog token budget", () => { + const documented = Tool.make({ + description: "Look up a record", + input: { + type: "object", + properties: { + id: { type: "string", description: "A detailed identifier description. ".repeat(20) }, + }, + required: ["id"], + } as const, + run: () => Effect.succeed("ok"), + }) + const runtime = CodeMode.make({ + tools: { records: { lookup: documented } }, + discovery: { catalogBudget: 40 }, + }) + + expect(runtime.catalog()[0]?.signature).toContain("/** A detailed identifier description.") + expect(runtime.instructions()).toContain( + "Available tools (PARTIAL - 0 of 1 shown; find the rest with tools.$codemode.search)", + ) + expect(runtime.instructions()).not.toContain("tools.records.lookup(input:") + }) + test("decodes tool input and output before exposing either side", async () => { const observed: Array = [] const transformed = Tool.make({ @@ -1031,17 +1086,27 @@ describe("CodeMode public contract", () => { expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError) expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError) - expect(() => CodeMode.make({ tools, discovery: { maxInlineCatalogTokens: -1 } })).toThrow(RangeError) + expect(() => CodeMode.make({ tools, discovery: { catalogBudget: -1 } })).toThrow(RangeError) const result = await Effect.runPromise( CodeMode.make({ tools, - discovery: { maxInlineCatalogTokens: 0 }, + discovery: { catalogBudget: 0 }, }).execute(`return await tools.$codemode.search({ query: "order", limit: 0.5 })`), ) expect(result.ok).toBe(false) if (result.ok) return expect(result.error.kind).toBe("InvalidToolInput") + + for (const offset of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, "1"]) { + const invalidOffset = await Effect.runPromise( + CodeMode.make({ tools }).execute( + `return await tools.$codemode.search({ query: "order", offset: ${JSON.stringify(offset)} })`, + ), + ) + expect(invalidOffset.ok).toBe(false) + if (!invalidOffset.ok) expect(invalidOffset.error.kind).toBe("InvalidToolInput") + } }) test("enforces the tool-call limit as a diagnostic", async () => { diff --git a/packages/codemode/test/enumeration.test.ts b/packages/codemode/test/enumeration.test.ts index 87c075a79282..f176fe2dc895 100644 --- a/packages/codemode/test/enumeration.test.ts +++ b/packages/codemode/test/enumeration.test.ts @@ -41,7 +41,7 @@ describe("Object.keys over tool references", () => { const namespaces = Object.keys(tools) return { namespaces, count: namespaces.length } `), - ).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 }) + ).toEqual({ namespaces: ["github", "memory", "playwright", "$codemode"], count: 4 }) }) test("enumerates tool names at a nested namespace", async () => { @@ -52,7 +52,7 @@ describe("Object.keys over tool references", () => { expect(await value(`return Object.keys(tools.github.list_issues)`)).toEqual([]) }) - test("the virtual discovery namespace enumerates its callable surface", async () => { + test("the internal discovery namespace enumerates its callable surface", async () => { expect(await value(`return Object.keys(tools.$codemode)`)).toEqual(["search"]) }) @@ -137,7 +137,7 @@ describe("for...in", () => { ).toBe("only") }) - test("enumerates namespaces and tools from the host tool tree", async () => { + test("enumerates namespaces and tools from the callable tool tree", async () => { expect( await value(` const names = [] @@ -146,7 +146,13 @@ describe("for...in", () => { } return names `), - ).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"]) + ).toEqual([ + "github.list_issues", + "github.get_issue", + "memory.search", + "playwright.navigate", + "$codemode.search", + ]) }) test("unsupported values fail with a hint at for...of and Object.keys", async () => { diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index 4f25645de22c..53a3b766d58a 100644 --- a/packages/codemode/test/signature.test.ts +++ b/packages/codemode/test/signature.test.ts @@ -40,21 +40,21 @@ describe("pretty signature rendering", () => { [ "{", " /** Repository owner */", - " owner: string", + " owner: string,", " /** Cursor from the previous response's pageInfo */", - " after?: string", + " after?: string,", " /**", " * Results per page", " * @default 30", " */", - " perPage?: number", + " perPage?: number,", " /**", " * Filter by labels", " * @minItems 1", " * @maxItems 10", " */", - " labels?: Array", - ' state?: "open" | "closed"', + " labels?: Array,", + ' state?: "open" | "closed",', "}", ].join("\n"), ) @@ -83,7 +83,7 @@ describe("pretty signature rendering", () => { true, ) expect(pretty).toBe( - ["{", " /** Search filter */", " filter?: {", " /** Issue state */", " state?: string", " }", "}"].join( + ["{", " /** Search filter */", " filter?: {", " /** Issue state */", " state?: string,", " },", "}"].join( "\n", ), ) @@ -91,10 +91,10 @@ describe("pretty signature rendering", () => { test("Effect Schema annotations become JSDoc on input and output fields", () => { expect(inputTypeScript(lookupOrder, true)).toBe( - ["{", " /** Order identifier */", " id: string", " verbose?: boolean", "}"].join("\n"), + ["{", " /** Order identifier */", " id: string,", " verbose?: boolean,", "}"].join("\n"), ) expect(outputTypeScript(lookupOrder, true)).toBe( - ["{", " /** Current order status */", " status: string", "}"].join("\n"), + ["{", " /** Current order status */", " status: string,", "}"].join("\n"), ) }) @@ -129,7 +129,7 @@ describe("pretty signature rendering", () => { { type: "object", properties: { size: { type: "number", default: 1n } } }, true, ) - expect(pretty).toBe(["{", " size?: number", "}"].join("\n")) + expect(pretty).toBe(["{", " size?: number,", "}"].join("\n")) }) test("neutralizes */ inside descriptions so nothing closes the comment early", () => { @@ -150,7 +150,7 @@ describe("pretty signature rendering", () => { true, ) expect(pretty).toBe( - ["{", " /**", " * First line", " *", " * Second line", " */", " query?: string", "}"].join("\n"), + ["{", " /**", " * First line", " *", " * Second line", " */", " query?: string,", "}"].join("\n"), ) }) @@ -235,12 +235,12 @@ describe("non-identifier property names render as quoted keys", () => { expect(jsonSchemaToTypeScript(rawSchema, true)).toBe( [ "{", - ' "123"?: number', - ' "foo-bar"?: string', - ' "@type": string', + ' "123"?: number,', + ' "foo-bar"?: string,', + ' "@type": string,', " /** Dotted name */", - ' "x.y"?: number', - " plain?: boolean", + ' "x.y"?: number,', + " plain?: boolean,", "}", ].join("\n"), ) @@ -259,7 +259,7 @@ describe("non-identifier property names render as quoted keys", () => { }) expect(inputTypeScript(tool)).toContain('"foo-bar"?: string') expect(outputTypeScript(tool)).toBe('{ "content-type": string }') - expect(outputTypeScript(tool, true)).toBe(["{", ' "content-type": string', "}"].join("\n")) + expect(outputTypeScript(tool, true)).toBe(["{", ' "content-type": string,', "}"].join("\n")) }) test("Effect Schema structs with non-identifier field names quote too", () => { @@ -269,7 +269,7 @@ describe("non-identifier property names render as quoted keys", () => { run: () => Effect.succeed(null), }) expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }') - expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string', " plain?: number", "}"].join("\n")) + expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string,', " plain?: number,", "}"].join("\n")) }) }) @@ -332,7 +332,7 @@ describe("union schemas render every alternative", () => { }) }) -describe("pretty signatures in search results", () => { +describe("JSDoc signatures in catalogs and search results", () => { const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } }) const search = async (query: string) => { @@ -341,7 +341,7 @@ describe("pretty signatures in search results", () => { ) expect(result.ok).toBe(true) if (!result.ok) throw new Error("search failed") - return result.value as { items: Array<{ path: string; signature: string }>; total: number } + return result.value as { items: Array<{ path: string; signature: string }>; remaining: number } } test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and tags", async () => { @@ -351,21 +351,21 @@ describe("pretty signatures in search results", () => { [ "tools.github.list_issues(input: {", " /** Repository owner */", - " owner: string", + " owner: string,", " /** Cursor from the previous response's pageInfo */", - " after?: string", + " after?: string,", " /**", " * Results per page", " * @default 30", " */", - " perPage?: number", + " perPage?: number,", " /**", " * Filter by labels", " * @minItems 1", " * @maxItems 10", " */", - " labels?: Array", - ' state?: "open" | "closed"', + " labels?: Array,", + ' state?: "open" | "closed",', "}): Promise", ].join("\n"), ) @@ -379,26 +379,26 @@ describe("pretty signatures in search results", () => { [ "tools.orders.lookup(input: {", " /** Order identifier */", - " id: string", - " verbose?: boolean", + " id: string,", + " verbose?: boolean,", "}): Promise<{", " /** Current order status */", - " status: string", + " status: string,", "}>", ].join("\n"), ) } }) - test("the inline catalog line for the same tool stays single-line compact", () => { + test("the inline catalog uses the same JSDoc signatures", async () => { const instructions = runtime.instructions() - expect(instructions).toContain( - ' - tools.github.list_issues(input: { owner: string; after?: string; perPage?: number; labels?: Array; state?: "open" | "closed" }): Promise // List issues in a repository', - ) - expect(instructions).toContain( - " - tools.orders.lookup(input: { id: string; verbose?: boolean }): Promise<{ status: string }> // Look up an order", - ) - expect(instructions).not.toContain("/**") + const github = (await search("list issues repository")).items.find( + ({ path }) => path === "tools.github.list_issues", + )! + const orders = (await search("look up order")).items.find(({ path }) => path === "tools.orders.lookup")! + expect(instructions).toContain(` - ${github.signature} // List issues in a repository`) + expect(instructions).toContain(` - ${orders.signature} // Look up an order`) + expect(instructions).toContain("/** Repository owner */") }) }) @@ -421,7 +421,7 @@ describe("non-identifier tool paths", () => { const instructions = runtime.instructions() expect(instructions).toContain( - 'tools.context7["resolve-library-id"](input: { query: string; libraryName: string }): Promise', + 'tools.context7["resolve-library-id"](input: {\n query: string,\n libraryName: string,\n}): Promise', ) expect(instructions).toContain("Do not infer or normalize tool names") expect(instructions).toContain("bracket notation and quotes are part of the path") diff --git a/packages/opencode/src/tool/code-mode.ts b/packages/opencode/src/tool/code-mode.ts index 0f566a49ade0..332d4b43f150 100644 --- a/packages/opencode/src/tool/code-mode.ts +++ b/packages/opencode/src/tool/code-mode.ts @@ -11,18 +11,11 @@ import { Plugin } from "@/plugin" export const CODE_MODE_TOOL = "execute" -const DESCRIPTION = [ - "Execute a JavaScript/TypeScript program that orchestrates the connected MCP tools inside a confined runtime.", - "The full usage guide and the catalog of available tools follow below.", -].join("\n") +const DESCRIPTION = "Run a confined orchestration script with access to connected MCP tools." export const Parameters = Schema.Struct({ code: Schema.String.annotate({ - description: [ - "JavaScript source to execute.", - "Inside CodeMode, `tools` contains only the MCP/CodeMode tools listed in this execute tool's description; top-level opencode tools like bash, read, or lsp are not available unless listed there.", - "Call available tools using the exact signatures shown in this execute tool's description, compose the results, and `return` the final value.", - ].join(" "), + description: "Script body executed by the confined interpreter.", }), }) diff --git a/packages/opencode/test/tool/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts index f36b6c866ef5..671acd896222 100644 --- a/packages/opencode/test/tool/code-mode-integration.test.ts +++ b/packages/opencode/test/tool/code-mode-integration.test.ts @@ -177,8 +177,10 @@ describe("code mode integration (real MCP server)", () => { test("the appended catalog inlines full signatures with real MCP schemas", () => { expect(description).toContain("Available tools (COMPLETE list") expect(description).toContain("- fixtures (4 tools)") - expect(description).toContain("tools.fixtures.add(input: { a: number; b: number }): Promise<{ sum: number }>") - expect(description).toContain("tools.fixtures.get_text(input: { name: string }): Promise") + expect(description).toContain( + "tools.fixtures.add(input: {\n a: number,\n b: number,\n}): Promise<{\n sum: number,\n}>", + ) + expect(description).toContain("tools.fixtures.get_text(input: {\n name: string,\n}): Promise") expect(description).toContain("// Add two numbers and return the structured sum") expect(description).not.toContain("$codemode") expect(description).toContain("## Workflow") diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index a220f14530fe..0a2b0b7fe424 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -98,6 +98,13 @@ describe("code mode execute", () => { const decode = Schema.decodeUnknownEffect(Parameters) await expect(Effect.runPromise(decode({ code: "return 1" }))).resolves.toEqual({ code: "return 1" }) await expect(Effect.runPromise(decode({}))).rejects.toThrow() + expect(Schema.toJsonSchemaDocument(Parameters).schema).toMatchObject({ + properties: { + code: { + description: "Script body executed by the confined interpreter.", + }, + }, + }) }) test("groups multi-underscore server names by longest matching prefix", () => { @@ -124,13 +131,13 @@ describe("code mode execute", () => { }, ["weather"], ) - expect(description).toContain("tools.weather.current(input: { city: string }): Promise<{ tempC: number }>") + expect(description).toContain("tools.weather.current(input: {\n city: string,\n}): Promise<{\n tempC: number,\n}>") }) test("the static base description carries no catalog; the registry appends it", async () => { const tool = await build({ github_list_issues: mcpTool("list_issues", () => "") }) expect(tool.id).toBe(CODE_MODE_TOOL) - expect(tool.description).toContain("confined runtime") + expect(tool.description).toBe("Run a confined orchestration script with access to connected MCP tools.") expect(tool.description).not.toContain("Available tools") expect(tool.description).not.toContain("list_issues") }) @@ -150,7 +157,7 @@ describe("code mode execute", () => { expect(description).toContain("- github (2 tools)") expect(description).toContain("- linear (1 tool)") expect(description).toContain( - "tools.github.create_issue(input: { title: string; body?: string }): Promise", + "tools.github.create_issue(input: {\n title: string,\n body?: string,\n}): Promise", ) expect(description).toContain("tools.github.list_issues(") expect(description).toContain("tools.linear.search(") @@ -159,9 +166,8 @@ describe("code mode execute", () => { expect(description).not.toContain("Browse one namespace") expect(description).toContain("## Workflow") expect(description).toContain("1. Pick a tool from the list under `## Available tools`") - expect(description).toContain( - '`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string', - ) + expect(description).not.toContain("JSON.parse(res)") + expect(description).toContain("check that it is a non-null object and not an array") expect(description).toContain("Return only the fields you need") expect(description).not.toContain("total_count") }) @@ -180,7 +186,7 @@ describe("code mode execute", () => { ), }) expect(description).toContain( - "tools.weather.current(input: { city: string }): Promise<{ tempC: number; summary?: string }>", + "tools.weather.current(input: {\n city: string,\n}): Promise<{\n tempC: number,\n summary?: string,\n}>", ) }) @@ -207,9 +213,16 @@ describe("code mode execute", () => { expect(description).toContain("Available tools (PARTIAL - ") expect(description).toMatch(/- alpha \(150 tools, \d+ shown\)/) expect(description).toContain("- zeta (1 tool)\n") - expect(description).toContain("tools.zeta.only_tool(input: { topic: string }): Promise") + expect(description).toContain( + "tools.zeta.only_tool(input: {\n /** Subject to look up */\n topic: string,\n}): Promise", + ) expect(description).toContain("tools.$codemode.search(") - expect(description).toContain("1. If the exact signature is not listed below, first search:") + expect(description).toContain(" limit?: number,\n offset?: number,") + expect(description).toContain(" remaining: number,\n next: {") + expect(description).toContain(" offset: number,\n } | null,") + expect(description).toContain( + '1. If needed, discover tools: `return await tools.$codemode.search({ query: "" })`.', + ) expect(description).toContain( '- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.', ) @@ -219,17 +232,18 @@ describe("code mode execute", () => { const tool = await build(tools, ["alpha", "zeta"]) const out = await Effect.runPromise( - tool.execute({ code: "return await tools.$codemode.search({ query: 'only tool', limit: 3 })" }, ctx), + tool.execute({ code: "return await tools.$codemode.search({ query: 'only tool', limit: 3, offset: 0 })" }, ctx), ) const result = JSON.parse(out.output) expect(result.items.map((i: any) => i.path)).toContain("tools.zeta.only_tool") + expect(result).toMatchObject({ remaining: 0, next: null }) expect(result.items[0].signature).toContain("tools.") const signature = result.items.find((i: any) => i.path === "tools.zeta.only_tool").signature expect(signature).toContain("tools.zeta.only_tool(input: {\n") expect(signature).toContain(" /** Subject to look up */\n topic: string") - expect(description).not.toContain("/**") + expect(description).toContain("/** Subject to look up */") expect(out.metadata.toolCalls).toEqual([ - { tool: "$codemode.search", status: "completed", input: { query: "only tool", limit: 3 } }, + { tool: "$codemode.search", status: "completed", input: { query: "only tool", limit: 3, offset: 0 } }, ]) }) @@ -240,7 +254,7 @@ describe("code mode execute", () => { expect(output.metadata.toolCalls).toEqual([]) }) - test("Object.keys(tools) enumerates the MCP server namespaces", async () => { + test("Object.keys(tools) enumerates the MCP server and CodeMode namespaces", async () => { const tool = await build({ github_list_issues: mcpTool("list_issues", () => ""), linear_search: mcpTool("search", () => ""), @@ -251,7 +265,7 @@ describe("code mode execute", () => { ctx, ), ) - expect(JSON.parse(output.output)).toEqual({ namespaces: ["github", "linear"], count: 2 }) + expect(JSON.parse(output.output)).toEqual({ namespaces: ["github", "linear", "$codemode"], count: 3 }) }) test("calls a namespaced MCP tool and flows its text result back into the program", async () => { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index f3ccd5997c19..c8c5fac59559 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -132,7 +132,7 @@ describe("tool.registry", () => { expect(ids).toContain("execute") expect(tools.map((tool) => tool.id)).toContain("execute") - expect(execute?.description).toContain("tools.weather.current(input: { city: string })") + expect(execute?.description).toContain("tools.weather.current(input: {\n city: string,\n})") }), ) From e12cb7fb6b22604cee6e97b3c4ef8fddc21d1bd9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 6 Jul 2026 05:53:41 +0000 Subject: [PATCH 04/19] chore: generate --- packages/codemode/src/tool-runtime.ts | 39 ++++++------------- packages/codemode/test/codemode.test.ts | 3 +- packages/codemode/test/enumeration.test.ts | 8 +--- packages/codemode/test/signature.test.ts | 12 ++++-- packages/opencode/test/tool/code-mode.test.ts | 4 +- 5 files changed, 26 insertions(+), 40 deletions(-) diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 29b27cb5c6ca..48d8209a48f4 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -390,8 +390,7 @@ const makeSearchTool = (searchIndex: ReadonlyArray) => pathQuery === "" ? undefined : scoped.find( - (entry) => - entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed, + (entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed, ) const terms = tokenize(query).map(termForms) // Additive field-weighted scoring, summed across terms: exact path or path segment @@ -418,8 +417,7 @@ const makeSearchTool = (searchIndex: ReadonlyArray) => .filter(({ score }) => terms.length === 0 || score > 0) .sort( (left, right) => - right.score - left.score || - left.entry.description.path.localeCompare(right.entry.description.path), + right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path), ) .map(({ entry }) => entry) const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({ @@ -479,10 +477,7 @@ export const assertValidTools = (tools: HostTools): void => { * namespace. Namespace stub lines are never budgeted: every namespace appears with its * tool count even at budget 0. */ -export const prepare = ( - tools: HostTools, - catalogBudget = defaultCatalogBudget, -): DiscoveryPlan => { +export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBudget): DiscoveryPlan => { if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) { throw new RangeError("discovery.catalogBudget must be a non-negative safe integer") } @@ -644,10 +639,7 @@ export const prepare = ( * function in JS). An unknown path is an `UnknownTool` error pointing at the working * discovery idioms, mirroring how calling an unknown tool fails. */ -const namespaceKeys = ( - tools: HostTools, - path: ReadonlyArray, -): ReadonlyArray => { +const namespaceKeys = (tools: HostTools, path: ReadonlyArray): ReadonlyArray => { let value: HostTool | Definition | HostTools = tools for (const segment of path) { if ( @@ -656,13 +648,9 @@ const namespaceKeys = ( isDefinition(value) || !Object.hasOwn(value, segment) ) { - throw new ToolRuntimeError( - "UnknownTool", - `Unknown tool namespace '${path.join(".")}'.`, - [ - "Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.", - ], - ) + throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${path.join(".")}'.`, [ + "Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.", + ]) } value = value[segment] as HostTool | Definition | HostTools } @@ -670,10 +658,7 @@ const namespaceKeys = ( return Object.keys(value) } -const resolve = ( - tools: HostTools, - path: ReadonlyArray, -): HostTool | Definition => { +const resolve = (tools: HostTools, path: ReadonlyArray): HostTool | Definition => { let value: HostTool | Definition | HostTools = tools for (const segment of path) { @@ -683,11 +668,9 @@ const resolve = ( isDefinition(value) || !Object.hasOwn(value, segment) ) { - throw new ToolRuntimeError( - "UnknownTool", - `Unknown tool '${path.join(".")}'.`, - ["Use tools.$codemode.search({ query }) to find available described tools."], - ) + throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, [ + "Use tools.$codemode.search({ query }) to find available described tools.", + ]) } value = value[segment] as HostTool | Definition | HostTools } diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 168dc93d5e26..f5a7169cf553 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -533,7 +533,8 @@ describe("CodeMode public contract", () => { { path: "tools.orders.lookup", description: "Look up an order by ID", - signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>", + signature: + "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>", }, ], remaining: 0, diff --git a/packages/codemode/test/enumeration.test.ts b/packages/codemode/test/enumeration.test.ts index f176fe2dc895..0de3dc3ea0dd 100644 --- a/packages/codemode/test/enumeration.test.ts +++ b/packages/codemode/test/enumeration.test.ts @@ -146,13 +146,7 @@ describe("for...in", () => { } return names `), - ).toEqual([ - "github.list_issues", - "github.get_issue", - "memory.search", - "playwright.navigate", - "$codemode.search", - ]) + ).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate", "$codemode.search"]) }) test("unsupported values fail with a hint at for...of and Object.keys", async () => { diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index 53a3b766d58a..2d07d2f234e9 100644 --- a/packages/codemode/test/signature.test.ts +++ b/packages/codemode/test/signature.test.ts @@ -83,9 +83,15 @@ describe("pretty signature rendering", () => { true, ) expect(pretty).toBe( - ["{", " /** Search filter */", " filter?: {", " /** Issue state */", " state?: string,", " },", "}"].join( - "\n", - ), + [ + "{", + " /** Search filter */", + " filter?: {", + " /** Issue state */", + " state?: string,", + " },", + "}", + ].join("\n"), ) }) diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index 0a2b0b7fe424..34b3faa610d7 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -131,7 +131,9 @@ describe("code mode execute", () => { }, ["weather"], ) - expect(description).toContain("tools.weather.current(input: {\n city: string,\n}): Promise<{\n tempC: number,\n}>") + expect(description).toContain( + "tools.weather.current(input: {\n city: string,\n}): Promise<{\n tempC: number,\n}>", + ) }) test("the static base description carries no catalog; the registry appends it", async () => { From 14df88eab514e7e5d61e5a3f72279818fc64e948 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 06:08:59 +0000 Subject: [PATCH 05/19] fix(app): preserve provider dialog backdrop (#35370) Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: Brendan Allan --- .../components/dialog-connect-provider.tsx | 26 ++-- .../src/components/dialog-custom-provider.tsx | 17 +-- .../components/dialog-select-model-unpaid.tsx | 2 +- .../src/components/dialog-select-provider.tsx | 128 ++++++++++-------- .../app/src/components/settings-providers.tsx | 9 +- .../src/components/settings-v2/providers.tsx | 9 +- .../pages/session/usage-exceeded-dialogs.tsx | 11 +- 7 files changed, 107 insertions(+), 95 deletions(-) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 3a57769b5a68..c94cca505351 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -17,19 +17,17 @@ import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" -export function DialogConnectProvider(props: { provider: string; directory?: Accessor }) { +export function DialogConnectProvider(props: { + provider: string + directory?: Accessor + onBack: () => void +}) { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() const language = useLanguage() const providers = useProviders(props.directory) - const all = () => { - void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) - }) - } - const alive = { value: true } const timer = { current: undefined as ReturnType | undefined } @@ -364,19 +362,11 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc } function goBack() { - if (methods().length === 1) { - all() - return - } - if (store.authorization) { - dispatch({ type: "method.reset" }) - return - } - if (store.methodIndex !== undefined) { + if (methods().length > 1 && store.methodIndex !== undefined) { dispatch({ type: "method.reset" }) return } - all() + props.onBack() } function MethodSelection() { @@ -600,6 +590,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc return ( } + transition >
diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 647e5002a297..e8cae859e06a 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -6,18 +6,16 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { useMutation } from "@tanstack/solid-query" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@/utils/toast" -import { type Accessor, batch, For } from "solid-js" +import { batch, For } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" -import { DialogSelectProvider } from "./dialog-select-provider" type Props = { - back?: "providers" | "close" - directory?: Accessor + onBack: () => void } export function DialogCustomProvider(props: Props) { @@ -36,14 +34,6 @@ export function DialogCustomProvider(props: Props) { err: {}, }) - const goBack = () => { - if (props.back === "close") { - dialog.close() - return - } - dialog.show(() => ) - } - const addModel = () => { setForm( "models", @@ -164,12 +154,13 @@ export function DialogCustomProvider(props: Props) { return ( } diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index bcb4a2fbcbf4..0a32ad48b36d 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -24,7 +24,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props const connect = (provider: string) => { void import("./dialog-connect-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx index ff0ab5d2bf0c..e1facf48219a 100644 --- a/packages/app/src/components/dialog-select-provider.tsx +++ b/packages/app/src/components/dialog-select-provider.tsx @@ -1,5 +1,5 @@ -import { type Accessor, Component, Show } from "solid-js" -import { useDialog } from "@opencode-ai/ui/context/dialog" +import { type Accessor, Component, Match, Show, Switch } from "solid-js" +import { createStore } from "solid-js/store" import { popularProviders, useProviders } from "@/hooks/use-providers" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" @@ -12,9 +12,13 @@ import { DialogCustomProvider } from "./dialog-custom-provider" const CUSTOM_ID = "_custom" export const DialogSelectProvider: Component<{ directory?: Accessor }> = (props) => { - const dialog = useDialog() const providers = useProviders(props.directory) const language = useLanguage() + const [store, setStore] = createStore({ selected: undefined as string | undefined }) + + function showPicker() { + setStore("selected", undefined) + } const popularGroup = () => language.t("dialog.provider.group.popular") const otherGroup = () => language.t("dialog.provider.group.other") @@ -27,61 +31,67 @@ export const DialogSelectProvider: Component<{ directory?: Accessor - x?.id} - items={() => { - language.locale() - return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()] - }} - filterKeys={["id", "name"]} - groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} - sortBy={(a, b) => { - if (a.id === CUSTOM_ID) return -1 - if (b.id === CUSTOM_ID) return 1 - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - sortGroupsBy={(a, b) => { - const popular = popularGroup() - if (a.category === popular && b.category !== popular) return -1 - if (b.category === popular && a.category !== popular) return 1 - return 0 - }} - onSelect={(x) => { - if (!x) return - if (x.id === CUSTOM_ID) { - dialog.show(() => ) - return - } - dialog.show(() => ) - }} - > - {(i) => ( -
- - {i.name} - -
{language.t("dialog.provider.opencode.tagline")}
-
- - {language.t("settings.providers.tag.custom")} - - - {language.t("dialog.provider.tag.recommended")} - - {(value) =>
{value()}
}
- - {language.t("dialog.provider.tag.recommended")} - -
- )} -
-
+ + + + + + {(provider) => } + + + + x?.id} + items={() => { + language.locale() + return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()] + }} + filterKeys={["id", "name"]} + groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} + sortBy={(a, b) => { + if (a.id === CUSTOM_ID) return -1 + if (b.id === CUSTOM_ID) return 1 + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + sortGroupsBy={(a, b) => { + const popular = popularGroup() + if (a.category === popular && b.category !== popular) return -1 + if (b.category === popular && a.category !== popular) return 1 + return 0 + }} + onSelect={(x) => { + if (!x) return + setStore("selected", x.id) + }} + > + {(i) => ( +
+ + {i.name} + +
{language.t("dialog.provider.opencode.tagline")}
+
+ + {language.t("settings.providers.tag.custom")} + + + {language.t("dialog.provider.tag.recommended")} + + {(value) =>
{value()}
}
+ + {language.t("dialog.provider.tag.recommended")} + +
+ )} +
+
+
+
) } diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 24e7a60104ae..9791fc21fcae 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -211,7 +211,12 @@ const SettingsProvidersContent: Component = () => { variant="secondary" icon="plus-small" onClick={() => { - dialog.show(() => ) + dialog.show(() => ( + dialog.show(() => )} + /> + )) }} > {language.t("common.connect")} @@ -239,7 +244,7 @@ const SettingsProvidersContent: Component = () => { variant="secondary" icon="plus-small" onClick={() => { - dialog.show(() => ) + dialog.show(() => ) }} > {language.t("common.connect")} diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index cd24bbd4558c..99a4101f9a35 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -211,7 +211,12 @@ export const SettingsProvidersV2: Component = () => { variant="neutral" icon="plus" onClick={() => { - dialog.show(() => ) + dialog.show(() => ( + dialog.show(() => )} + /> + )) }} > {language.t("common.connect")} @@ -241,7 +246,7 @@ export const SettingsProvidersV2: Component = () => { variant="neutral" icon="plus" onClick={() => { - dialog.show(() => ) + dialog.show(() => ) }} > {language.t("common.connect")} diff --git a/packages/app/src/pages/session/usage-exceeded-dialogs.tsx b/packages/app/src/pages/session/usage-exceeded-dialogs.tsx index 935eed7e6969..5c77e7bcecc0 100644 --- a/packages/app/src/pages/session/usage-exceeded-dialogs.tsx +++ b/packages/app/src/pages/session/usage-exceeded-dialogs.tsx @@ -77,7 +77,16 @@ export function useUsageExceededDialogs() { if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now()) else { void import("../../components/dialog-connect-provider").then((x) => - dialog.show(() => ), + dialog.show(() => ( + { + void import("../../components/dialog-select-provider").then((provider) => { + dialog.show(() => ) + }) + }} + /> + )), ) } }} From 3a149ba71ca7859e1b599b12fc34ef99dd797c4e Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:26:03 +1000 Subject: [PATCH 06/19] fix(app): optimize large review panes (#35375) --- .../review-pane-scaling-benchmark.spec.ts | 312 +++++++++++++ .../e2e/regression/review-tab-switch.spec.ts | 40 +- .../review-terminal-stacked.spec.ts | 212 +++++++++ .../src/components/file-tree-v2-model.test.ts | 46 ++ .../app/src/components/file-tree-v2-model.ts | 77 +++ packages/app/src/components/file-tree-v2.tsx | 441 ++++++------------ .../components/virtual-scroll-element.test.ts | 18 + .../src/components/virtual-scroll-element.ts | 4 + packages/app/src/pages/session.tsx | 18 +- .../src/pages/session/session-side-panel.tsx | 37 +- .../session/v2/review-diff-kinds.test.ts | 7 + .../src/pages/session/v2/review-diff-kinds.ts | 3 +- .../src/pages/session/v2/review-panel-v2.tsx | 1 - .../pages/session/v2/session-file-list-v2.tsx | 139 ++++-- .../app/test-browser/solid-virtual.test.ts | 15 + ...-review-file-preview-v2-virtualize.test.ts | 13 + ...ssion-review-file-preview-v2-virtualize.ts | 5 + .../session-review-file-preview-v2.tsx | 5 + .../src/v2/components/session-review-v2.tsx | 85 ++-- 19 files changed, 1074 insertions(+), 404 deletions(-) create mode 100644 packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts create mode 100644 packages/app/e2e/regression/review-terminal-stacked.spec.ts create mode 100644 packages/app/src/components/file-tree-v2-model.test.ts create mode 100644 packages/app/src/components/file-tree-v2-model.ts create mode 100644 packages/app/src/components/virtual-scroll-element.test.ts create mode 100644 packages/app/src/components/virtual-scroll-element.ts create mode 100644 packages/session-ui/src/v2/components/session-review-file-preview-v2-virtualize.test.ts create mode 100644 packages/session-ui/src/v2/components/session-review-file-preview-v2-virtualize.ts diff --git a/packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts b/packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts new file mode 100644 index 000000000000..81de0a055f90 --- /dev/null +++ b/packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts @@ -0,0 +1,312 @@ +import type { Page } from "@playwright/test" +import { benchmark, expect } from "../benchmark" +import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture" + +const changedLinesPerFile = 100 +const linesPerSide = changedLinesPerFile / 2 +const fileCounts = [1, 10, 100, 1_000, 10_000] +const filesPerDirectory = 100 +const readyFrames = 3 +const completionTimeoutMs = Number(process.env.REVIEW_PANE_COMPLETION_TIMEOUT_MS ?? 900_000) + +type ReviewPaneScalingSample = { + observedAtMs: number + logicalRows: number + treeRows: number + fileRows: number + diffLines: number + header: string + ready: boolean +} + +type ReviewPaneScalingProbe = { + startedAt?: number + firstTreeRowMs?: number + logicalTreeReadyMs?: number + firstDiffRenderMs?: number + stableReadyMs?: number + samples: ReviewPaneScalingSample[] + frameTimesMs: number[] + longTasks: { startTime: number; duration: number }[] + stop: () => void +} + +benchmark.describe("performance: review pane scaling", () => { + for (const fileCount of fileCounts) { + const changedLines = fileCount * changedLinesPerFile + + benchmark( + `${changedLines} changed lines across ${fileCount} ${fileCount === 1 ? "file" : "files"}`, + async ({ page, report }) => { + benchmark.setTimeout(1_200_000) + await page.emulateMedia({ reducedMotion: "reduce" }) + + const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY) + if (Number.isNaN(patchByteLimit) || patchByteLimit < 0) + throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`) + const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit)) + await setupTimelineBenchmark(page, { + historyTurns: 0, + eventBatch: 1, + newLayoutDesigns: true, + }) + await page.route("**/vcs/diff**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: responseBody, + }), + ) + + const expectedRows = fileCount + 2 + Math.ceil(fileCount / filesPerDirectory) + const metrics = await measureReviewPaneLoad(page, { + expectedFile: reviewFile(0), + expectedRows, + }) + const search = await measureBroadReviewSearch(page, fileCount) + + expect(metrics.logicalRows).toBe(expectedRows) + expect(metrics.fileRows).toBeGreaterThan(0) + expect(metrics.treeRows).toBeGreaterThan(0) + expect(metrics.diffLines).toBeGreaterThan(0) + expect(search.logicalRows).toBe(fileCount) + expect(search.renderedRows).toBeGreaterThan(0) + report( + { ...metrics, search }, + { + fileCount, + changedLinesPerFile, + changedLines, + additions: changedLines / 2, + deletions: changedLines / 2, + patchLines: changedLines, + patchByteLimit: Number.isFinite(patchByteLimit) ? patchByteLimit : null, + payloadBytes: new TextEncoder().encode(responseBody).byteLength, + expectedRows, + }, + ) + }, + ) + } +}) + +async function measureBroadReviewSearch(page: Page, expectedRows: number) { + const filter = page.getByRole("searchbox", { name: "Filter files" }) + await filter.evaluate((element) => { + element.addEventListener( + "input", + () => { + ;(window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt = performance.now() + }, + { once: true, capture: true }, + ) + }) + await filter.fill("file-") + + return page.evaluate((expectedRows) => { + const startedAt = (window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt! + return new Promise<{ stableMs: number; logicalRows: number; renderedRows: number }>((resolve) => { + let previous = -1 + let streak = 0 + const sample = () => { + const tree = document.querySelector('#review-panel [data-component="file-tree-v2"]') + const rows = [...document.querySelectorAll('#review-panel [data-slot="file-tree-v2-row"]')] + const logicalRows = Number(tree?.dataset.totalRows ?? rows.length) + const ready = + logicalRows === expectedRows && rows.length > 0 && rows.every((row) => row.textContent?.includes("file-")) + streak = ready && rows.length === previous ? streak + 1 : ready ? 1 : 0 + previous = rows.length + if (streak >= 3) { + resolve({ stableMs: performance.now() - startedAt, logicalRows, renderedRows: rows.length }) + return + } + requestAnimationFrame(sample) + } + requestAnimationFrame(sample) + }) + }, expectedRows) +} + +function createScalingDiffs(fileCount: number, patchByteLimit: number) { + const changes = Array.from({ length: linesPerSide }, (_, index) => { + const line = String(index).padStart(3, "0") + return `-export const value_${line} = "before"\n+export const value_${line} = "after"` + }).join("\n") + let patchBytes = 0 + let capped = false + + return Array.from({ length: fileCount }, (_, index) => { + const file = reviewFile(index) + const fullPatch = [ + `diff --git a/${file} b/${file}`, + `--- a/${file}`, + `+++ b/${file}`, + `@@ -1,${linesPerSide} +1,${linesPerSide} @@`, + changes, + ].join("\n") + if (index === 0 && fullPatch.length > patchByteLimit) + throw new Error(`REVIEW_PANE_PATCH_BYTE_LIMIT must include the active patch (${fullPatch.length} bytes)`) + const patch = !capped && patchBytes + fullPatch.length <= patchByteLimit ? fullPatch : emptyReviewPatch(file) + if (patch === fullPatch) patchBytes += fullPatch.length + else capped = true + return { + file, + patch, + additions: linesPerSide, + deletions: linesPerSide, + status: "modified" as const, + } + }) +} + +function emptyReviewPatch(file: string) { + return [`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`].join("\n") +} + +function reviewFile(index: number) { + return `src/review/d${String(Math.floor(index / filesPerDirectory)).padStart(5, "0")}/file-${String(index).padStart(5, "0")}.ts` +} + +async function measureReviewPaneLoad(page: Page, input: { expectedFile: string; expectedRows: number }) { + const toggle = page.getByRole("button", { name: "Toggle review" }) + await expect(toggle).toBeVisible() + await toggle.evaluate((element) => element.setAttribute("data-review-pane-scaling-toggle", "")) + await installReviewPaneScalingProbe(page, input) + await toggle.click() + await page.waitForFunction( + () => + (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe + ?.stableReadyMs !== undefined, + undefined, + { timeout: completionTimeoutMs }, + ) + + return page.evaluate(() => { + const probe = (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe! + probe.stop() + const startedAt = probe.startedAt! + const final = probe.samples.at(-1)! + const resources = performance + .getEntriesByType("resource") + .filter((entry) => entry.name.includes("/vcs/diff")) as PerformanceResourceTiming[] + const resource = resources.at(-1) + const longTasks = probe.longTasks.filter( + (entry) => entry.startTime >= startedAt && entry.startTime <= startedAt + probe.stableReadyMs!, + ) + const frameGaps = probe.frameTimesMs.map((time, index) => time - (probe.frameTimesMs[index - 1] ?? 0)) + + return { + firstTreeRowMs: probe.firstTreeRowMs ?? null, + logicalTreeReadyMs: probe.logicalTreeReadyMs ?? null, + firstDiffRenderMs: probe.firstDiffRenderMs ?? null, + stableReadyMs: probe.stableReadyMs ?? null, + responseStartMs: resource ? resource.responseStart - startedAt : null, + responseEndMs: resource ? resource.responseEnd - startedAt : null, + responseToStableMs: resource ? probe.stableReadyMs! - (resource.responseEnd - startedAt) : null, + treeRows: final.treeRows, + logicalRows: final.logicalRows, + fileRows: final.fileRows, + diffLines: final.diffLines, + samples: probe.samples.length, + maxFrameGapMs: Math.max(0, ...frameGaps), + longTaskCount: longTasks.length, + longTaskTotalMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0), + maxLongTaskMs: Math.max(0, ...longTasks.map((entry) => entry.duration)), + } + }) +} + +async function installReviewPaneScalingProbe(page: Page, input: { expectedFile: string; expectedRows: number }) { + await page.evaluate( + ({ expectedFile, expectedRows, stableFrames }) => { + let running = true + let readyStreak = 0 + const basename = expectedFile.split("/").at(-1)! + const longTaskObserver = PerformanceObserver.supportedEntryTypes.includes("longtask") + ? new PerformanceObserver((list) => { + probe.longTasks.push( + ...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })), + ) + }) + : undefined + const probe: ReviewPaneScalingProbe = { + samples: [], + frameTimesMs: [], + longTasks: [], + stop: () => { + running = false + longTaskObserver?.disconnect() + }, + } + + const sample = (time: number) => { + if (!running || probe.startedAt === undefined) return + const panel = document.querySelector("#review-panel") + const tree = panel?.querySelector('[data-component="file-tree-v2"]') + const rows = panel?.querySelectorAll('[data-slot="file-tree-v2-row"]') ?? [] + const fileRows = panel?.querySelectorAll('button[data-slot="file-tree-v2-row"]') ?? [] + const header = + panel?.querySelector('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? "" + const viewers = panel + ? [...panel.querySelectorAll('[data-component="file"][data-mode="diff"]')] + : [] + const diffLines = viewers.reduce( + (sum, viewer) => + sum + (viewer.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0), + 0, + ) + const observedAtMs = time - probe.startedAt + const logicalRows = Number(tree?.dataset.totalRows ?? rows.length) + const ready = + logicalRows === expectedRows && + fileRows.length > 0 && + header.includes(basename) && + viewers.length === 1 && + diffLines > 0 + const previous = probe.samples.at(-1) + const stable = + ready && + previous?.ready === true && + previous.logicalRows === logicalRows && + previous.treeRows === rows.length && + previous.fileRows === fileRows.length && + previous.diffLines === diffLines && + previous.header === header + + probe.frameTimesMs.push(observedAtMs) + probe.samples.push({ + observedAtMs, + logicalRows, + treeRows: rows.length, + fileRows: fileRows.length, + diffLines, + header, + ready, + }) + if (probe.firstTreeRowMs === undefined && rows.length > 0) probe.firstTreeRowMs = observedAtMs + if (probe.logicalTreeReadyMs === undefined && logicalRows === expectedRows) + probe.logicalTreeReadyMs = observedAtMs + if (probe.firstDiffRenderMs === undefined && diffLines > 0) probe.firstDiffRenderMs = observedAtMs + readyStreak = !ready ? 0 : stable ? readyStreak + 1 : 1 + if (readyStreak === stableFrames) probe.stableReadyMs = observedAtMs + if (probe.stableReadyMs === undefined) requestAnimationFrame(sample) + } + + longTaskObserver?.observe({ type: "longtask", buffered: true }) + document.addEventListener( + "click", + (event) => { + const toggle = event.target instanceof Element ? event.target.closest("button") : undefined + if (!toggle?.hasAttribute("data-review-pane-scaling-toggle")) return + probe.startedAt = performance.now() + performance.mark("opencode.review-pane-scaling.click") + requestAnimationFrame(sample) + }, + { capture: true, once: true }, + ) + ;(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe = probe + }, + { ...input, stableFrames: readyFrames }, + ) +} diff --git a/packages/app/e2e/regression/review-tab-switch.spec.ts b/packages/app/e2e/regression/review-tab-switch.spec.ts index 92bf12d51cf5..c2ea406c5ab3 100644 --- a/packages/app/e2e/regression/review-tab-switch.spec.ts +++ b/packages/app/e2e/regression/review-tab-switch.spec.ts @@ -10,6 +10,9 @@ const sessionB = "ses_review_tab_b" const titleA = "Alpha session" const titleB = "Beta session" const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const diffs = Array.from({ length: 2_740 }, (_, index) => + fileDiff(`src/generated-${String(index).padStart(4, "0")}.ts`), +) // Marks the review pane DOM node so a remount (fresh node) is detectable. const PROBE = "original" @@ -25,20 +28,34 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac await expectSessionTitle(page, titleA) await page.getByRole("button", { name: "Toggle review" }).click() + const reviewTab = page.getByRole("tab", { name: /Review/ }) + const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ }) + await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel") + await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel") const review = page.locator('#review-panel [data-component="session-review-v2"]') await expectAppVisible(review) - await expectAppVisible(page.getByRole("button", { name: /example\.ts/ })) + await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) await writeProbe(page) await switchTab(page, titleB) await expectSessionTitle(page, titleB) await expectAppVisible(review) + await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) expect(await readProbe(page)).toBe(PROBE) await switchTab(page, titleA) await expectSessionTitle(page, titleA) await expectAppVisible(review) + await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) expect(await readProbe(page)).toBe(PROBE) + + const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') + await viewport.hover() + await page.mouse.wheel(0, 100_000) + await expect + .poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible() }) type Probed = HTMLElement & { __e2eProbe?: string } @@ -80,16 +97,7 @@ async function setup(page: Page) { default: { providerID: "opencode", modelID: "test" }, }, sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], - vcsDiff: [ - { - file: "src/example.ts", - additions: 1, - deletions: 1, - status: "modified", - patch: - "diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n", - }, - ], + vcsDiff: diffs, pageMessages: () => ({ items: [] }), }) @@ -127,3 +135,13 @@ function session(id: string, title: string, created: number) { function sessionHref(sessionID: string) { return `/server/${base64Encode(server)}/session/${sessionID}` } + +function fileDiff(file: string) { + return { + file, + additions: 1, + deletions: 1, + status: "modified", + patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`, + } +} diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts new file mode 100644 index 000000000000..1ba8ed9474cf --- /dev/null +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -0,0 +1,212 @@ +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewTerminalStacked" +const projectID = "proj_review_terminal_stacked" +const sessionID = "ses_review_terminal_stacked" +const title = "Review terminal stacked" +const branchDiffs = [ + fileDiff(".github/actions/setup-bun/action.yml", 7), + ...Array.from({ length: 2_739 }, (_, index) => + fileDiff(`src/branch/generated-${String(index).padStart(4, "0")}.ts`, 100), + ), +] + +test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => { + test.setTimeout(120_000) + await page.setViewportSize({ width: 1400, height: 900 }) + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "review-terminal-stacked", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: "review-terminal-stacked", + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + pageMessages: () => ({ items: [] }), + }) + await page.route(/\/vcs(?:\?.*)?$/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }), + }), + ) + await page.route("**/vcs/diff**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + new URL(route.request().url()).searchParams.get("mode") === "branch" + ? branchDiffs + : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)), + ), + }), + ) + await page.route("**/pty", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }), + }), + ) + await page.route("**/pty/pty_review_terminal", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + ) + await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + await expect(page.locator("#review-panel")).toBeVisible() + await expectTree(page, 8, "git-0.ts") + + await selectMode(page, "Git changes", "Branch changes") + await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible() + await page.keyboard.press("Control+Backquote") + await expect(page.locator("#terminal-panel")).toBeVisible() + await expectTree(page, 2_745, "action.yml") + await expectStackGeometry(page) + + const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') + await treeViewport.hover() + await page.mouse.wheel(0, 100_000) + await expect + .poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + const lastFile = page.getByRole("button", { name: "generated-2738.ts" }) + await expect(lastFile).toBeVisible() + const bottomGap = await lastFile.evaluate((element) => { + const viewport = element.closest(".scroll-view__viewport")!.getBoundingClientRect() + return viewport.bottom - element.getBoundingClientRect().bottom + }) + expect(bottomGap).toBeGreaterThanOrEqual(0) + expect(bottomGap).toBeLessThanOrEqual(16) + await selectMode(page, "Branch changes", "Git changes") + await expectTree(page, 8, "git-0.ts") + await selectMode(page, "Git changes", "Branch changes") + await expectTree(page, 2_745, "action.yml") + + const filter = page.getByRole("searchbox", { name: "Filter files" }) + await filter.fill("generated-2738") + await expectTree(page, 1, "generated-2738.ts") + await filter.fill("") + await expectTree(page, 2_745, "action.yml") + + await page.getByRole("button", { name: "Toggle file tree" }).click() + await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveAttribute("aria-hidden", "true") + await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1) + await page.getByRole("button", { name: "Toggle file tree" }).click() + await expectTree(page, 2_745, "action.yml") + + await page.keyboard.press("Control+Backquote") + await expect(page.locator("#terminal-panel")).toHaveCount(0) + await expectTree(page, 2_745, "action.yml") + await page.keyboard.press("Control+Backquote") + await expect(page.locator("#terminal-panel")).toBeVisible() + await expectTree(page, 2_745, "action.yml") + + await page.getByRole("button", { name: "Toggle review" }).click() + await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "true") + await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1) + await page.getByRole("button", { name: "Toggle review" }).click() + await expectTree(page, 2_745, "action.yml") + await page.setViewportSize({ width: 1_000, height: 700 }) + await expectTree(page, 2_745, "action.yml") + await expectStackGeometry(page) + await page.setViewportSize({ width: 1_000, height: 120 }) + await page.setViewportSize({ width: 1_400, height: 900 }) + await expectTree(page, 2_745, "action.yml") + await expectStackGeometry(page) +}) + +async function selectMode(page: Page, current: string, next: string) { + await page.getByRole("button", { name: current }).click() + const option = page.getByRole("option", { name: next }) + await expect(option).toBeVisible() + await option.click() +} + +async function expectTree(page: Page, total: number, file: string) { + await expectMountedTree(page, total) + await expect(page.getByRole("button", { name: file })).toBeVisible() +} + +async function expectMountedTree(page: Page, total: number) { + const tree = page.locator('#review-panel [data-component="file-tree-v2"]') + await expect(tree).toHaveAttribute("data-total-rows", String(total)) + await expect + .poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length)) + .toBeGreaterThan(0) + const state = await tree.evaluate((element) => ({ + root: element.getBoundingClientRect().height, + viewport: element.closest(".scroll-view__viewport")!.getBoundingClientRect().height, + rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length, + })) + expect(state.viewport).toBeGreaterThan(0) + expect(state.root).toBeGreaterThan(0) + expect(state.rows).toBeGreaterThan(0) + expect(state.rows).toBeLessThanOrEqual(60) +} + +async function expectStackGeometry(page: Page) { + const geometry = await page.evaluate(() => { + const review = document.querySelector("#review-panel")! + const terminal = document.querySelector("#terminal-panel")! + const reviewParent = review.parentElement!.getBoundingClientRect() + const terminalParent = terminal.parentElement!.getBoundingClientRect() + return { + review: review.getBoundingClientRect().height, + reviewParent: reviewParent.height, + terminal: terminal.getBoundingClientRect().height, + terminalParent: terminalParent.height, + } + }) + expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1) + expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1) +} + +function base64Encode(value: string) { + return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") +} + +function fileDiff(file: string, additions: number) { + return { + file, + additions, + deletions: 0, + status: "modified", + patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`, + } +} diff --git a/packages/app/src/components/file-tree-v2-model.test.ts b/packages/app/src/components/file-tree-v2-model.test.ts new file mode 100644 index 000000000000..288f7112e929 --- /dev/null +++ b/packages/app/src/components/file-tree-v2-model.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model" + +describe("file tree v2 model", () => { + test("builds sorted depth-first rows", () => { + const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"]) + + expect(model.total).toBe(8) + expect(flattenFileTreeV2(model, () => true).map((row) => [row.node.path, row.node.type, row.level])).toEqual([ + ["docs", "directory", 0], + ["docs/guide.md", "file", 1], + ["src", "directory", 0], + ["src/lib", "directory", 1], + ["src/lib/a.ts", "file", 2], + ["src/lib/b.ts", "file", 2], + ["src/z.ts", "file", 1], + ["README.md", "file", 0], + ]) + }) + + test("omits descendants of collapsed directories", () => { + const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"]) + + expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([ + "src", + "src/lib", + "src/z.ts", + ]) + }) + + test("normalizes separators and duplicate paths", () => { + const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"]) + const rows = flattenFileTreeV2(model, () => true) + + expect(model.total).toBe(4) + expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"]) + expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts") + }) + + test("supports paths deeper than the legacy recursion limit", () => { + const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts` + const model = buildFileTreeV2Model([file]) + + expect(flattenFileTreeV2(model, () => true)).toHaveLength(131) + }) +}) diff --git a/packages/app/src/components/file-tree-v2-model.ts b/packages/app/src/components/file-tree-v2-model.ts new file mode 100644 index 000000000000..2127b6800f86 --- /dev/null +++ b/packages/app/src/components/file-tree-v2-model.ts @@ -0,0 +1,77 @@ +import type { FileNode } from "@opencode-ai/sdk/v2" + +export type FileTreeV2Model = { + children: ReadonlyMap + total: number +} + +export type FileTreeV2Node = FileNode & { originalPath: string } + +export type FileTreeV2Row = { + node: FileTreeV2Node + level: number +} + +export function normalizeFileTreeV2Path(value: string) { + return value + .replaceAll("\\", "/") + .replace(/^\/+|\/+$/g, "") + .replace(/\/{2,}/g, "/") +} + +export function buildFileTreeV2Model(paths: readonly string[]): FileTreeV2Model { + const nodes = new Map() + + paths.forEach((value) => { + const file = normalizeFileTreeV2Path(value) + if (!file) return + + const parts = file.split("/") + parts.forEach((name, index) => { + const path = parts.slice(0, index + 1).join("/") + if (nodes.has(path)) return + nodes.set(path, { + name, + path, + absolute: path, + type: index === parts.length - 1 ? "file" : "directory", + ignored: false, + originalPath: index === parts.length - 1 ? value : path, + }) + }) + }) + + const children = new Map() + nodes.forEach((node) => { + const index = node.path.lastIndexOf("/") + const parent = index === -1 ? "" : node.path.slice(0, index) + const list = children.get(parent) + if (list) list.push(node) + else children.set(parent, [node]) + }) + children.forEach((nodes) => + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === "directory" ? -1 : 1 + return a.name.localeCompare(b.name) + }), + ) + + return { children, total: nodes.size } +} + +export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: string) => boolean) { + const rows: FileTreeV2Row[] = [] + const stack = (model.children.get("") ?? []).toReversed().map((node) => ({ node, level: 0 })) + + while (stack.length > 0) { + const row = stack.pop()! + rows.push(row) + if (row.node.type !== "directory" || !expanded(row.node.path)) continue + const children = model.children.get(row.node.path) ?? [] + for (let index = children.length - 1; index >= 0; index--) { + stack.push({ node: children[index]!, level: row.level + 1 }) + } + } + + return rows +} diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx index 302d9b58a353..c712a8044360 100644 --- a/packages/app/src/components/file-tree-v2.tsx +++ b/packages/app/src/components/file-tree-v2.tsx @@ -1,95 +1,26 @@ import { useFile } from "@/context/file" -import { Collapsible } from "@opencode-ai/ui/collapsible" import { FileIcon } from "@opencode-ai/ui/file-icon" import "@opencode-ai/ui/v2/file-tree-v2.css" import { createEffect, createMemo, + createSignal, For, - Match, - on, Show, splitProps, - Switch, - untrack, type ComponentProps, type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" import type { FileNode } from "@opencode-ai/sdk/v2" import { Icon } from "@opencode-ai/ui/v2/icon" -import { - dirsToExpand, - pathToFileUrl, - shouldListRoot, - visibleKind, - withFileDragImage, - type Filter, - type Kind, -} from "@/components/file-tree" +import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" +import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" +import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" +import { virtualScrollElement } from "@/components/virtual-scroll-element" export type { Kind } from "@/components/file-tree" -const MAX_DEPTH = 128 - -function visibleNodesForPath(path: string, children: (dir: string) => FileNode[], current: Filter | undefined) { - const nodes = children(path) - if (!current) return nodes - - const parent = (item: string) => { - const idx = item.lastIndexOf("/") - if (idx === -1) return "" - return item.slice(0, idx) - } - - const leaf = (item: string) => { - const idx = item.lastIndexOf("/") - return idx === -1 ? item : item.slice(idx + 1) - } - - const out = nodes.filter((node) => { - if (node.type === "file") return current.files.has(node.path) - return current.dirs.has(node.path) - }) - - const seen = new Set(out.map((node) => node.path)) - - for (const dir of current.dirs) { - if (parent(dir) !== path) continue - if (seen.has(dir)) continue - out.push({ - name: leaf(dir), - path: dir, - absolute: dir, - type: "directory", - ignored: false, - }) - seen.add(dir) - } - - for (const item of current.files) { - if (parent(item) !== path) continue - if (seen.has(item)) continue - out.push({ - name: leaf(item), - path: item, - absolute: item, - type: "file", - ignored: false, - }) - seen.add(item) - } - - out.sort((a, b) => { - if (a.type !== b.type) { - return a.type === "directory" ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) - - return out -} - const INDENT_STEP = 16 function rowPaddingLeft(level: number, type: FileNode["type"]) { @@ -123,7 +54,6 @@ const FileTreeNodeV2 = ( active?: string draggable: boolean kinds?: ReadonlyMap - marks?: Set as?: "div" | "button" }, ) => { @@ -133,18 +63,18 @@ const FileTreeNodeV2 = ( "active", "draggable", "kinds", - "marks", "as", "children", "class", "classList", ]) - const kind = () => visibleKind(local.node, local.kinds, local.marks) + const kind = () => local.kinds?.get(local.node.path) return ( + {(_, index) => ( +
+ )} + + ) +} + export default function FileTreeV2(props: { - path: string active?: string - level?: number allowed?: readonly string[] kinds?: ReadonlyMap draggable?: boolean onFileClick?: (file: FileNode) => void - - _filter?: Filter - _marks?: Set - _deeps?: Map - _kinds?: ReadonlyMap - _chain?: readonly string[] }) { const file = useFile() - const level = props.level ?? 0 const draggable = () => props.draggable ?? true - - const key = (p: string) => - file - .normalize(p) - .replace(/[\\/]+$/, "") - .replaceAll("\\", "/") - const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)] - - const filter = createMemo(() => { - if (props._filter) return props._filter - - const allowed = props.allowed - if (!allowed) return - - const files = new Set(allowed) - const dirs = new Set() - - for (const item of allowed) { - const parts = item.split("/") - const parents = parts.slice(0, -1) - for (const [idx] of parents.entries()) { - const dir = parents.slice(0, idx + 1).join("/") - if (dir) dirs.add(dir) - } - } - - return { files, dirs } - }) - - const marks = createMemo(() => { - if (props._marks) return props._marks - - const out = new Set(props.kinds?.keys() ?? []) - if (out.size === 0) return - return out - }) - - const kinds = createMemo(() => { - if (props._kinds) return props._kinds - return props.kinds - }) - - const deeps = createMemo(() => { - if (props._deeps) return props._deeps - - const out = new Map() - - const root = props.path - if (!(file.tree.state(root)?.expanded ?? false)) return out - - const seen = new Set() - const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = [] - - const push = (dir: string, lvl: number) => { - const id = key(dir) - if (seen.has(id)) return - seen.add(id) - - const kids = file.tree - .children(dir) - .filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false)) - .map((node) => node.path) - - stack.push({ dir, lvl, i: 0, kids, max: lvl }) - } - - push(root, level - 1) - - while (stack.length > 0) { - const top = stack[stack.length - 1]! - - if (top.i < top.kids.length) { - const next = top.kids[top.i]! - top.i++ - push(next, top.lvl + 1) - continue - } - - out.set(top.dir, top.max) - stack.pop() - - const parent = stack[stack.length - 1] - if (!parent) continue - parent.max = Math.max(parent.max, top.max) - } - - return out + const active = () => normalizeFileTreeV2Path(props.active ?? "") + const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? [])) + const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true)) + const [root, setRoot] = createSignal() + const [focused, setFocused] = createSignal() + const virtualizer = createVirtualizer({ + get count() { + return rows().length + }, + getScrollElement: () => virtualScrollElement(root()), + initialRect: { width: 0, height: 600 }, + estimateSize: () => 28, + gap: 2, + overscan: 10, + get getItemKey() { + const current = rows() + return (index: number) => current[index]?.node.path ?? index + }, + rangeExtractor: (range) => { + const indexes = defaultRangeExtractor(range) + const path = focused() + const index = path ? rows().findIndex((row) => row.node.path === path) : -1 + if (index < 0 || indexes.includes(index)) return indexes + return [...indexes, index].sort((a, b) => a - b) + }, }) - createEffect(() => { - const current = filter() - const dirs = dirsToExpand({ - level, - filter: current, - expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false, + const path = active() + if (!path) return + const index = rows().findIndex((row) => row.node.path === path) + if (index < 0) return + queueMicrotask(() => { + if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return + virtualizer.scrollToIndex(index, { align: "auto" }) }) - // Nodes come from the `allowed` filter; skip listing so directories that only - // exist on the diff's base branch do not each fail with an error toast. - for (const dir of dirs) file.tree.expand(dir, { list: false }) }) - - createEffect( - on( - () => props.path, - (path) => { - const dir = untrack(() => file.tree.state(path)) - if (!shouldListRoot({ level, dir })) return - void file.tree.list(path) - }, - { defer: false }, - ), + const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const))) + const virtualItemByKey = createMemo( + () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), ) - - const nodes = createMemo(() => visibleNodesForPath(props.path, file.tree.children, filter())) + const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key)) return ( - // group/file-tree-v2 scopes the group-hover guide lines below; hosts may add - // an outer group with the same name to widen the hover area. -
- - {(node) => { - const expanded = () => file.tree.state(node.path)?.expanded ?? false - const deep = () => deeps().get(node.path) ?? -1 - const hasChildren = () => visibleNodesForPath(node.path, file.tree.children, filter()).length > 0 - return ( - - - - open ? file.tree.expand(node.path, { list: false }) : file.tree.collapse(node.path) - } - > - - + + {(key) => ( + + {(item) => ( +
+ + {(row) => ( + setFocused(row().node.path)} + onBlur={() => setFocused(undefined)} + onClick={() => + props.onFileClick?.({ + ...row().node, + path: row().node.originalPath, + absolute: row().node.originalPath, + }) + } + > + + 0}> +
+ + + + + + + } > -
- -
- - - - -
- ...
} + setFocused(row().node.path)} + onBlur={() => setFocused(undefined)} + aria-expanded={file.tree.state(row().node.path)?.expanded ?? true} + onClick={() => + file.tree.state(row().node.path)?.expanded === false + ? file.tree.expand(row().node.path, { list: false }) + : file.tree.collapse(row().node.path) + } > - -
- - - - - - props.onFileClick?.(node)} - > - 0}> -
- - } - > - - - - - - - - - ) - }} + +
+ +
+ + + )} + +
+ )} +
+ )}
) diff --git a/packages/app/src/components/virtual-scroll-element.test.ts b/packages/app/src/components/virtual-scroll-element.test.ts new file mode 100644 index 000000000000..20c25a8561a0 --- /dev/null +++ b/packages/app/src/components/virtual-scroll-element.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test" +import { virtualScrollElement } from "./virtual-scroll-element" + +test("resolves the connected viewport that owns the virtual root", () => { + const stale = document.createElement("div") + stale.className = "scroll-view__viewport" + const viewport = document.createElement("div") + viewport.className = "scroll-view__viewport" + const root = document.createElement("div") + viewport.append(root) + document.body.append(viewport) + + expect(virtualScrollElement(root)).toBe(viewport) + expect(virtualScrollElement(root)).not.toBe(stale) + + viewport.remove() + expect(virtualScrollElement(root)).toBeNull() +}) diff --git a/packages/app/src/components/virtual-scroll-element.ts b/packages/app/src/components/virtual-scroll-element.ts new file mode 100644 index 000000000000..8708781d86a7 --- /dev/null +++ b/packages/app/src/components/virtual-scroll-element.ts @@ -0,0 +1,4 @@ +export function virtualScrollElement(root: HTMLElement | undefined) { + if (!root?.isConnected) return null + return root.closest(".scroll-view__viewport") +} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 8b422f713edc..46bd907167a4 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -2157,6 +2157,7 @@ export default function Page() { diffsReady={reviewReady} empty={reviewEmptyText} hasReview={hasReview} + reviewHasFocusableContent={hasReview} reviewCount={reviewCount} reviewPanel={reviewPanel} activeDiff={tree.activeDiff} @@ -2168,14 +2169,20 @@ export default function Page() {
- -
+ +
hasReview() || reviewV2State.sidebarOpened()} reviewCount={reviewCount} reviewPanel={reviewPanelV2} activeDiff={tree.activeDiff} @@ -2204,7 +2211,12 @@ export default function Page() {
-
+
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 690262102039..3f44aba48832 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -14,6 +14,9 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import FileTree from "@/components/file-tree" import { SessionContextUsage } from "@/components/session-context-usage" + +const reviewTabID = "session-side-panel-review-tab" +const reviewTabPanelID = "session-side-panel-review-tabpanel" import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" import { useCommand } from "@/context/command" import { useFile, type SelectedLineRange } from "@/context/file" @@ -45,6 +48,7 @@ export function SessionSidePanel(props: { diffsReady: () => boolean empty: () => string hasReview: () => boolean + reviewHasFocusableContent: () => boolean reviewCount: () => number reviewPanel: () => JSX.Element activeDiff?: string @@ -75,6 +79,7 @@ export function SessionSidePanel(props: { }), ) const open = createMemo(() => reviewOpen() || fileOpen()) + const rendered = createMemo((previous) => previous || open(), false) const reviewTab = createMemo(() => isDesktop()) const panelWidth = createMemo(() => { if (!open()) return "0px" @@ -155,6 +160,10 @@ export function SessionSidePanel(props: { const openedTabs = tabState.openedTabs const activeTab = tabState.activeTab const activeFileTab = tabState.activeFileTab + const reviewContentRendered = createMemo( + (previous) => previous || (reviewOpen() && activeTab() === "review"), + false, + ) const fileTreeTab = () => layout.fileTree.tab() @@ -223,7 +232,7 @@ export function SessionSidePanel(props: { class="relative min-w-0 flex overflow-hidden bg-background-base" classList={{ "h-full shrink-0": !props.stacked, - "min-h-0 flex-1": props.stacked, + "h-full min-h-0": props.stacked, "pointer-events-none": !open(), "transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none": !props.size.active() && !props.reviewSnap, @@ -232,7 +241,7 @@ export function SessionSidePanel(props: { }} style={{ width: panelWidth() }} > - +
- +
{language.t("session.tab.review")}
@@ -328,10 +341,20 @@ export function SessionSidePanel(props: {
- - - {props.reviewPanel()} - + +
+ {props.reviewPanel()} +
diff --git a/packages/app/src/pages/session/v2/review-diff-kinds.test.ts b/packages/app/src/pages/session/v2/review-diff-kinds.test.ts index b5a26cb32147..ee37a2c8b4c9 100644 --- a/packages/app/src/pages/session/v2/review-diff-kinds.test.ts +++ b/packages/app/src/pages/session/v2/review-diff-kinds.test.ts @@ -12,6 +12,13 @@ describe("reviewDiffKinds", () => { expect(kinds.get("src/b.ts")).toBe("del") expect(kinds.get("src")).toBe("mix") }) + + test("normalizes file and directory paths", () => { + const kinds = reviewDiffKinds([{ file: "\\src//lib/a.ts/", additions: 1, deletions: 1, status: "modified" }]) + + expect(kinds.get("src/lib/a.ts")).toBe("mix") + expect(kinds.get("src/lib")).toBe("mix") + }) }) describe("filterReviewFiles", () => { diff --git a/packages/app/src/pages/session/v2/review-diff-kinds.ts b/packages/app/src/pages/session/v2/review-diff-kinds.ts index 4d22d9a67a5c..c288c357068d 100644 --- a/packages/app/src/pages/session/v2/review-diff-kinds.ts +++ b/packages/app/src/pages/session/v2/review-diff-kinds.ts @@ -1,10 +1,11 @@ import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { Kind } from "@/components/file-tree-v2" +import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff export function normalizePath(p: string) { - return p.replaceAll("\\", "/").replace(/\/+$/, "") + return normalizeFileTreeV2Path(p) } export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index 4b8e6359c8c6..fcd52756ab08 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -203,7 +203,6 @@ function ReviewPanelV2Sidebar(props: { when={props.searching()} fallback={ normalizePath(props.active ?? "") const highlighted = () => normalizePath(props.highlighted ?? "") - let rootRef: HTMLDivElement | undefined + const normalized = createMemo(() => props.files.map(normalizePath)) + const [root, setRoot] = createSignal() + const [focused, setFocused] = createSignal() + const virtualizer = createVirtualizer({ + get count() { + return props.files.length + }, + getScrollElement: () => virtualScrollElement(root()), + initialRect: { width: 0, height: 600 }, + estimateSize: () => 28, + gap: 2, + overscan: 10, + get getItemKey() { + const files = props.files + return (index: number) => files[index] ?? index + }, + rangeExtractor: (range) => { + const indexes = defaultRangeExtractor(range) + const path = focused() + const index = path ? props.files.indexOf(path) : -1 + if (index < 0 || indexes.includes(index)) return indexes + return [...indexes, index].sort((a, b) => a - b) + }, + }) createEffect(() => { - highlighted() - if (!rootRef) return + const index = normalized().indexOf(highlighted()) + if (index < 0) return queueMicrotask(() => { - const row = rootRef?.querySelector('[data-slot="file-tree-v2-row"][data-highlighted]') - row?.scrollIntoView({ block: "nearest" }) + if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return + virtualizer.scrollToIndex(index, { align: "auto" }) }) }) + const virtualItemByKey = createMemo( + () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), + ) + const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key)) return (
{ - rootRef = el - }} + ref={setRoot} data-component="file-tree-v2" + data-total-rows={props.files.length} + style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }} > - - {(path) => { - const normalized = normalizePath(path) - const selected = () => { - if (highlighted()) return highlighted() === normalized - return active() === normalized - } - const highlightedRow = () => highlighted() === normalized - const kind = () => props.kinds?.get(normalized) - const directory = () => (normalized.includes("/") ? getDirectory(normalized) : undefined) - const filename = () => getFilename(normalized) + + {(key) => { + const path = key as string + const value = normalizePath(path) + const selected = () => (highlighted() ? highlighted() === value : active() === value) + const highlightedRow = () => highlighted() === value + const kind = () => props.kinds?.get(value) + const directory = () => (value.includes("/") ? getDirectory(value) : undefined) + const filename = () => getFilename(value) return ( - + + {(item) => ( +
+ +
+ )} +
) }}
diff --git a/packages/app/test-browser/solid-virtual.test.ts b/packages/app/test-browser/solid-virtual.test.ts index 727248d606e8..716fa7fa9ea7 100644 --- a/packages/app/test-browser/solid-virtual.test.ts +++ b/packages/app/test-browser/solid-virtual.test.ts @@ -27,6 +27,21 @@ test("reactive count updates preserve measured row sizes", () => { }) }) +test("initial rect projects rows before a scroll element connects", () => { + createRoot((dispose) => { + const virtualizer = createVirtualizer({ + count: 100, + getScrollElement: () => null, + estimateSize: () => 28, + initialRect: { width: 0, height: 600 }, + overscan: 10, + }) + + expect(virtualizer.getVirtualItems().length).toBeGreaterThan(0) + dispose() + }) +}) + test("logical scroll offset includes pending measurement adjustments", () => { createRoot((dispose) => { const virtualizer = createVirtualizer({ diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2-virtualize.test.ts b/packages/session-ui/src/v2/components/session-review-file-preview-v2-virtualize.test.ts new file mode 100644 index 000000000000..3bbf173c8bbd --- /dev/null +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2-virtualize.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { shouldVirtualizeReviewDiff } from "./session-review-file-preview-v2-virtualize" + +describe("shouldVirtualizeReviewDiff", () => { + test("renders small diffs directly", () => { + expect(shouldVirtualizeReviewDiff({ additionLines: 500, deletionLines: 500 })).toBe(false) + }) + + test("virtualizes large diffs", () => { + expect(shouldVirtualizeReviewDiff({ additionLines: 501, deletionLines: 1 })).toBe(true) + expect(shouldVirtualizeReviewDiff({ additionLines: 1, deletionLines: 501 })).toBe(true) + }) +}) diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2-virtualize.ts b/packages/session-ui/src/v2/components/session-review-file-preview-v2-virtualize.ts new file mode 100644 index 000000000000..7795a859619f --- /dev/null +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2-virtualize.ts @@ -0,0 +1,5 @@ +const lineThreshold = 500 + +export function shouldVirtualizeReviewDiff(input: { additionLines: number; deletionLines: number }) { + return Math.max(input.additionLines, input.deletionLines) > lineThreshold +} diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx index c2607971dff1..3e7bd830519e 100644 --- a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx @@ -22,6 +22,7 @@ import type { } from "../../components/session-review" import type { SessionReviewExpandMode } from "./session-review-v2" import { createLineCommentControllerV2 } from "./line-comment-annotations-v2" +import { shouldVirtualizeReviewDiff } from "./session-review-file-preview-v2-virtualize" import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import "./session-review-v2.css" @@ -219,6 +220,10 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop preloadedDiff={view().preloaded} diffStyle={props.diffStyle} expandUnchanged={expandUnchanged()} + virtualize={shouldVirtualizeReviewDiff({ + additionLines: view().fileDiff.additionLines.length, + deletionLines: view().fileDiff.deletionLines.length, + })} hunkSeparators={view().fileDiff.isPartial ? "simple" : "line-info-basic"} enableLineSelection={lineCommentsEnabled()} enableGutterUtility={lineCommentsEnabled()} diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx index 34ee36532ab5..176dce4fc983 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -11,6 +11,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { makeEventListener } from "@solid-primitives/event-listener" import { Show, createEffect, createMemo, createSignal, type JSX } from "solid-js" +import { getWorkerPool } from "../../pierre/worker" import "./session-review-v2.css" export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT = 240 @@ -48,6 +49,7 @@ export type SessionReviewV2SidebarProps = { onWidthChange?: (width: number) => void minWidth?: number maxWidth?: number + viewportRef?: (element: HTMLDivElement) => void children?: JSX.Element } @@ -74,44 +76,47 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) { inert={!props.open} style={{ width: props.open ? `${width()}px` : "0px" }} > - -
-
{props.title}
- {props.stats} -
-
- props.onFilterChange(event.currentTarget.value)} - onKeyDown={props.onFilterKeyDown} - showClearButton={props.filter.length > 0} - clearLabel={i18n.t("ui.list.clearFilter")} - onClearClick={() => props.onFilterChange("")} - placeholder={i18n.t("ui.sessionReviewV2.filterFiles")} - aria-label={i18n.t("ui.sessionReviewV2.filterFiles")} - leadingIcon={ - - } - /> -
- - {props.children} - -
+
+
{props.title}
+ {props.stats} +
+
+ props.onFilterChange(event.currentTarget.value)} + onKeyDown={props.onFilterKeyDown} + showClearButton={props.filter.length > 0} + clearLabel={i18n.t("ui.list.clearFilter")} + onClearClick={() => props.onFilterChange("")} + placeholder={i18n.t("ui.sessionReviewV2.filterFiles")} + aria-label={i18n.t("ui.sessionReviewV2.filterFiles")} + leadingIcon={ + + } + /> +
+ + {props.children} +
setResizing(true)}> @@ -131,6 +136,10 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) { export function SessionReviewV2(props: SessionReviewV2Props) { const i18n = useI18n() + createEffect(() => { + getWorkerPool(props.diffStyle) + }) + const fileIndex = () => { const files = props.files if (files.length === 0) return -1 From 38bb38ecb2b50f81c9dd8e943288a5eaebb180df Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:50:08 +0800 Subject: [PATCH 07/19] refactor(app): unify provider connect dialog (#35518) --- .../components/dialog-connect-provider.tsx | 281 ++++++++++++---- .../src/components/dialog-custom-provider.tsx | 318 +++++++++--------- .../src/components/dialog-manage-models.tsx | 6 +- .../components/dialog-select-model-unpaid.tsx | 13 +- .../src/components/dialog-select-model.tsx | 8 +- .../src/components/dialog-select-provider.tsx | 97 ------ .../app/src/components/dialog-settings.tsx | 17 +- .../app/src/components/settings-providers.tsx | 33 +- .../settings-v2/dialog-settings-v2.tsx | 16 +- .../src/components/settings-v2/providers.tsx | 33 +- packages/app/src/pages/layout.tsx | 4 +- .../pages/session/usage-exceeded-dialogs.tsx | 17 +- 12 files changed, 441 insertions(+), 402 deletions(-) delete mode 100644 packages/app/src/components/dialog-select-provider.tsx diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index c94cca505351..6499642b3896 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -7,20 +7,167 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { List, type ListRef } from "@opencode-ai/ui/list" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" +import { Tag } from "@opencode-ai/ui/tag" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@/utils/toast" -import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" +import { + type Accessor, + type Component, + createEffect, + createMemo, + createResource, + Match, + onCleanup, + onMount, + Show, + Switch, +} from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" -import { useProviders } from "@/hooks/use-providers" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { CustomProviderForm } from "./dialog-custom-provider" -export function DialogConnectProvider(props: { +const CUSTOM_ID = "_custom" + +export function useProviderConnectController(options: { onBack?: () => void } = {}) { + const [store, setStore] = createStore({ selected: undefined as string | undefined }) + const reset = () => setStore("selected", undefined) + + return { + selected: () => store.selected, + select: (provider?: string) => setStore("selected", provider), + back: options.onBack ?? reset, + } +} + +export const DialogConnectProvider: Component<{ + directory?: Accessor + controller?: ReturnType +}> = (props) => { + const fallback = useProviderConnectController() + const controller = props.controller ?? fallback + const language = useLanguage() + const reset = controller.back + const back = { current: reset } + const select = (provider?: string) => { + back.current = reset + controller.select(provider) + } + + return ( + + back.current()} + aria-label={language.t("common.goBack")} + /> + + } + > + + + + + + {(provider) => ( + (back.current = handler)} + /> + )} + + + + + + + ) +} + +function ProviderPicker(props: { directory?: Accessor; onSelect: (provider: string) => void }) { + const providers = useProviders(props.directory) + const language = useLanguage() + const popularGroup = () => language.t("dialog.provider.group.popular") + const otherGroup = () => language.t("dialog.provider.group.other") + const customLabel = () => language.t("settings.providers.tag.custom") + const note = (id: string) => { + if (id === "anthropic") return language.t("dialog.provider.anthropic.note") + if (id === "openai") return language.t("dialog.provider.openai.note") + if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") + if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline") + return undefined + } + + return ( + x?.id} + items={() => { + language.locale() + return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()] + }} + filterKeys={["id", "name"]} + groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} + sortBy={(a, b) => { + if (a.id === CUSTOM_ID) return -1 + if (b.id === CUSTOM_ID) return 1 + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + sortGroupsBy={(a, b) => { + const popular = popularGroup() + if (a.category === popular && b.category !== popular) return -1 + if (b.category === popular && a.category !== popular) return 1 + return 0 + }} + onSelect={(x) => { + if (!x) return + props.onSelect(x.id) + }} + > + {(i) => ( +
+ + {i.name} + +
{language.t("dialog.provider.opencode.tagline")}
+
+ + {language.t("settings.providers.tag.custom")} + + + {language.t("dialog.provider.tag.recommended")} + + {(value) =>
{value()}
}
+ + {language.t("dialog.provider.tag.recommended")} + +
+ )} +
+ ) +} + +function ProviderConnection(props: { provider: string directory?: Accessor onBack: () => void + setBack: (handler: () => void) => void }) { const dialog = useDialog() const serverSync = useServerSync() @@ -369,6 +516,8 @@ export function DialogConnectProvider(props: { props.onBack() } + props.setBack(goBack) + function MethodSelection() { return ( <> @@ -589,81 +738,67 @@ export function DialogConnectProvider(props: { } return ( - - } - transition - > -
-
- -
- - - {language.t("provider.connect.title.anthropicProMax")} - - {language.t("provider.connect.title", { provider: provider().name })} - -
+
+
+ +
+ + + {language.t("provider.connect.title.anthropicProMax")} + + {language.t("provider.connect.title", { provider: provider().name })} +
-
-
- - -
-
- - {language.t("provider.connect.status.inProgress")} -
+
+
+
+ + +
+
+ + {language.t("provider.connect.status.inProgress")}
- - - - - -
-
- - {language.t("provider.connect.status.inProgress")} -
+
+
+ + + + +
+
+ + {language.t("provider.connect.status.inProgress")}
- - - - - -
-
- - {language.t("provider.connect.status.failed", { error: store.error ?? "" })} -
+
+
+ + + + +
+
+ + {language.t("provider.connect.status.failed", { error: store.error ?? "" })}
- - - - - - - - - - - - - - - -
+
+
+ + + + + + + + + + + + + +
-
+
) } diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index e8cae859e06a..363a2e390a49 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -19,6 +19,28 @@ type Props = { } export function DialogCustomProvider(props: Props) { + const language = useLanguage() + + return ( + + } + transition + > + + + ) +} + +export function CustomProviderForm() { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() @@ -153,169 +175,155 @@ export function DialogCustomProvider(props: Props) { } return ( - - } - transition - > -
-
- -
{language.t("provider.custom.title")}
-
+
+
+ +
{language.t("provider.custom.title")}
+
-
-

- {language.t("provider.custom.description.prefix")} - - {language.t("provider.custom.description.link")} - - {language.t("provider.custom.description.suffix")} -

+ +

+ {language.t("provider.custom.description.prefix")} + + {language.t("provider.custom.description.link")} + + {language.t("provider.custom.description.suffix")} +

-
- setField("providerID", v)} - validationState={form.err.providerID ? "invalid" : undefined} - error={form.err.providerID} - /> - setField("name", v)} - validationState={form.err.name ? "invalid" : undefined} - error={form.err.name} - /> - setField("baseURL", v)} - validationState={form.err.baseURL ? "invalid" : undefined} - error={form.err.baseURL} - /> - setField("apiKey", v)} - /> -
+
+ setField("providerID", v)} + validationState={form.err.providerID ? "invalid" : undefined} + error={form.err.providerID} + /> + setField("name", v)} + validationState={form.err.name ? "invalid" : undefined} + error={form.err.name} + /> + setField("baseURL", v)} + validationState={form.err.baseURL ? "invalid" : undefined} + error={form.err.baseURL} + /> + setField("apiKey", v)} + /> +
-
- - - {(m, i) => ( -
-
- setModel(i(), "id", v)} - validationState={m.err.id ? "invalid" : undefined} - error={m.err.id} - /> -
-
- setModel(i(), "name", v)} - validationState={m.err.name ? "invalid" : undefined} - error={m.err.name} - /> -
- removeModel(i())} - disabled={form.models.length <= 1} - aria-label={language.t("provider.custom.models.remove")} +
+ + + {(m, i) => ( +
+
+ setModel(i(), "id", v)} + validationState={m.err.id ? "invalid" : undefined} + error={m.err.id} />
- )} - - -
- -
- - - {(h, i) => ( -
-
- setHeader(i(), "key", v)} - validationState={h.err.key ? "invalid" : undefined} - error={h.err.key} - /> -
-
- setHeader(i(), "value", v)} - validationState={h.err.value ? "invalid" : undefined} - error={h.err.value} - /> -
- removeHeader(i())} - disabled={form.headers.length <= 1} - aria-label={language.t("provider.custom.headers.remove")} +
+ setModel(i(), "name", v)} + validationState={m.err.name ? "invalid" : undefined} + error={m.err.name} />
- )} - - -
+ removeModel(i())} + disabled={form.models.length <= 1} + aria-label={language.t("provider.custom.models.remove")} + /> +
+ )} +
+ +
- - -
-
+
+ + + +
) } diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index 0238f543c41f..d6c5e9918670 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -16,7 +16,7 @@ import { useLocal } from "@/context/local" import { popularProviders } from "@/hooks/use-providers" import { useLanguage } from "@/context/language" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogConnectProvider } from "./dialog-connect-provider" import { decode64 } from "@/utils/base64" import { SettingsListV2 } from "./settings-v2/parts/list" import { SettingsRowV2 } from "./settings-v2/parts/row" @@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => { const directory = () => decode64(local.slug()) const handleConnectProvider = () => { - dialog.show(() => ) + void dialog.show(() => ) } const providerRank = (id: string) => popularProviders.indexOf(id) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) @@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => { const directory = () => decode64(local.slug()) const handleConnectProvider = () => { - dialog.show(() => ) + void dialog.show(() => ) } const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) const providerVisible = (providerID: string) => diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index 0a32ad48b36d..4611a36c950e 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -22,17 +22,16 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props const providers = useProviders(directory) const language = useLanguage() - const connect = (provider: string) => { + const openProviders = (provider?: string) => { void import("./dialog-connect-provider").then((x) => { - dialog.show(() => ) + const controller = x.useProviderConnectController() + controller.select(provider) + void dialog.show(() => ) }) } - const all = () => { - void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) - }) - } + const connect = (provider: string) => openProviders(provider) + const all = () => openProviders() let listRef: ListRef | undefined const handleKeyDown = (e: KeyboardEvent) => { diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index f026f371eac0..cea5f34d0d9c 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -155,8 +155,8 @@ export function ModelSelectorPopover(props: { const handleConnectProvider = () => { close("provider") - void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + void import("./dialog-connect-provider").then((x) => { + void dialog.show(() => ) }) } const language = useLanguage() @@ -503,8 +503,8 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat const directory = () => decode64(local.slug()) const provider = () => { - void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + void import("./dialog-connect-provider").then((x) => { + void dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx deleted file mode 100644 index e1facf48219a..000000000000 --- a/packages/app/src/components/dialog-select-provider.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { type Accessor, Component, Match, Show, Switch } from "solid-js" -import { createStore } from "solid-js/store" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { Dialog } from "@opencode-ai/ui/dialog" -import { List } from "@opencode-ai/ui/list" -import { Tag } from "@opencode-ai/ui/tag" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { DialogConnectProvider } from "./dialog-connect-provider" -import { useLanguage } from "@/context/language" -import { DialogCustomProvider } from "./dialog-custom-provider" - -const CUSTOM_ID = "_custom" - -export const DialogSelectProvider: Component<{ directory?: Accessor }> = (props) => { - const providers = useProviders(props.directory) - const language = useLanguage() - const [store, setStore] = createStore({ selected: undefined as string | undefined }) - - function showPicker() { - setStore("selected", undefined) - } - - const popularGroup = () => language.t("dialog.provider.group.popular") - const otherGroup = () => language.t("dialog.provider.group.other") - const customLabel = () => language.t("settings.providers.tag.custom") - const note = (id: string) => { - if (id === "anthropic") return language.t("dialog.provider.anthropic.note") - if (id === "openai") return language.t("dialog.provider.openai.note") - if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") - if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline") - } - - return ( - - - - - - {(provider) => } - - - - x?.id} - items={() => { - language.locale() - return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()] - }} - filterKeys={["id", "name"]} - groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} - sortBy={(a, b) => { - if (a.id === CUSTOM_ID) return -1 - if (b.id === CUSTOM_ID) return 1 - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - sortGroupsBy={(a, b) => { - const popular = popularGroup() - if (a.category === popular && b.category !== popular) return -1 - if (b.category === popular && a.category !== popular) return 1 - return 0 - }} - onSelect={(x) => { - if (!x) return - setStore("selected", x.id) - }} - > - {(i) => ( -
- - {i.name} - -
{language.t("dialog.provider.opencode.tagline")}
-
- - {language.t("settings.providers.tag.custom")} - - - {language.t("dialog.provider.tag.recommended")} - - {(value) =>
{value()}
}
- - {language.t("dialog.provider.tag.recommended")} - -
- )} -
-
-
-
- ) -} diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index 20d71f4bfd44..231237eb8b91 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -4,19 +4,30 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { Icon } from "@opencode-ai/ui/icon" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { SettingsGeneral } from "./settings-general" import { SettingsKeybinds } from "./settings-keybinds" import { SettingsProviders } from "./settings-providers" import { SettingsModels } from "./settings-models" import { SettingsServers } from "./settings-servers" -export const DialogSettings: Component = () => { +export const DialogSettings: Component<{ defaultValue?: string }> = (props) => { const language = useLanguage() const platform = usePlatform() + const dialog = useDialog() + + const showProviders = () => { + void dialog.show(() => ) + } return ( - +
@@ -70,7 +81,7 @@ export const DialogSettings: Component = () => { - + diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 9791fc21fcae..bcd30edbc7de 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -8,8 +8,7 @@ import { createMemo, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" -import { DialogConnectProvider } from "./dialog-connect-provider" -import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider" import { DialogCustomProvider } from "./dialog-custom-provider" import { SettingsList } from "./settings-list" import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker" @@ -28,20 +27,26 @@ const PROVIDER_NOTES = [ { match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" }, ] as const -export const SettingsProviders: Component = () => { +export const SettingsProviders: Component<{ onBack?: () => void }> = (props) => { return ( - + ) } -const SettingsProvidersContent: Component = () => { +const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => { const dialog = useDialog() const language = useLanguage() const serverSDK = useServerSDK() const serverSync = useServerSync() const providers = useProviders() + const providerConnect = useProviderConnectController({ onBack: props.onBack }) + + const connect = (provider?: string) => { + providerConnect.select(provider) + void dialog.show(() => ) + } const connected = createMemo(() => { return providers @@ -206,19 +211,7 @@ const SettingsProvidersContent: Component = () => { {(key) => {language.t(key())}}
-
@@ -255,9 +248,7 @@ const SettingsProvidersContent: Component = () => { diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index a93a10dcb2f6..aee4fc9896ff 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -10,16 +10,28 @@ import { SettingsProvidersV2 } from "./providers" import { SettingsModelsV2 } from "./models" import "./settings-v2.css" import { SettingsServersV2 } from "./servers" +import { useDialog } from "@opencode-ai/ui/context/dialog" export const DialogSettings: Component<{ sessionID?: string + defaultValue?: string }> = (props) => { const language = useLanguage() const platform = usePlatform() + const dialog = useDialog() + + const showProviders = () => { + void dialog.show(() => ) + } return ( - +
@@ -73,7 +85,7 @@ export const DialogSettings: Component<{ - + diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index 99a4101f9a35..f945fa33c643 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -8,8 +8,7 @@ import { createMemo, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" -import { DialogConnectProvider } from "../dialog-connect-provider" -import { DialogSelectProvider } from "../dialog-select-provider" +import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider" import { DialogCustomProvider } from "../dialog-custom-provider" import { SettingsListV2 } from "./parts/list" import "./settings-v2.css" @@ -30,12 +29,18 @@ const PROVIDER_NOTES = [ const PROVIDER_ICON_SIZE = 16 -export const SettingsProvidersV2: Component = () => { +export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => { const dialog = useDialog() const language = useLanguage() const serverSdk = useServerSDK() const serverSync = useServerSync() const providers = useProviders() + const providerConnect = useProviderConnectController({ onBack: props.onBack }) + + const connect = (provider?: string) => { + providerConnect.select(provider) + void dialog.show(() => ) + } const connected = createMemo(() => { return providers @@ -206,19 +211,7 @@ export const SettingsProvidersV2: Component = () => {
- { - dialog.show(() => ( - dialog.show(() => )} - /> - )) - }} - > + connect(item.id)}> {language.t("common.connect")}
@@ -254,13 +247,7 @@ export const SettingsProvidersV2: Component = () => {
-
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 690460d3af6e..fd9d16b90ad6 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1095,9 +1095,9 @@ export default function LegacyLayout(props: ParentProps) { function connectProvider() { const run = ++dialogRun - void import("@/components/dialog-select-provider").then((x) => { + void import("@/components/dialog-connect-provider").then((x) => { if (dialogDead || dialogRun !== run) return - dialog.show(() => ) + void dialog.show(() => ) }) } diff --git a/packages/app/src/pages/session/usage-exceeded-dialogs.tsx b/packages/app/src/pages/session/usage-exceeded-dialogs.tsx index 5c77e7bcecc0..d56fa3d1f48c 100644 --- a/packages/app/src/pages/session/usage-exceeded-dialogs.tsx +++ b/packages/app/src/pages/session/usage-exceeded-dialogs.tsx @@ -76,18 +76,11 @@ export function useUsageExceededDialogs() { setGoUpsellState(keys.lastSeenAt, Date.now()) if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now()) else { - void import("../../components/dialog-connect-provider").then((x) => - dialog.show(() => ( - { - void import("../../components/dialog-select-provider").then((provider) => { - dialog.show(() => ) - }) - }} - /> - )), - ) + void import("../../components/dialog-connect-provider").then((x) => { + const controller = x.useProviderConnectController() + controller.select("opencode-go") + void dialog.show(() => ) + }) } }} /> From 377d5d22872cf18bcf98caabd04db5778399f27b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:12:30 +0800 Subject: [PATCH 08/19] fix(app): avoid shortcut settings flash (#35349) Co-authored-by: Jay <53023+jayair@users.noreply.github.com> --- packages/app/src/components/dialog-settings.tsx | 6 ++++-- packages/app/src/components/settings-keybinds.tsx | 14 ++++---------- .../components/settings-v2/dialog-settings-v2.tsx | 6 ++++-- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index 231237eb8b91..b554fa79a822 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -1,4 +1,4 @@ -import { Component } from "solid-js" +import { Component, createSignal, startTransition } from "solid-js" import { Dialog } from "@opencode-ai/ui/dialog" import { Tabs } from "@opencode-ai/ui/tabs" import { Icon } from "@opencode-ai/ui/icon" @@ -15,6 +15,7 @@ export const DialogSettings: Component<{ defaultValue?: string }> = (props) => { const language = useLanguage() const platform = usePlatform() const dialog = useDialog() + const [tab, setTab] = createSignal(props.defaultValue ?? "general") const showProviders = () => { void dialog.show(() => ) @@ -25,7 +26,8 @@ export const DialogSettings: Component<{ defaultValue?: string }> = (props) => { void startTransition(() => setTab(value))} class="h-full settings-dialog" > diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 98f6c9ffa04c..3e3db45bcf5f 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -5,24 +5,18 @@ import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextField } from "@opencode-ai/ui/text-field" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { showToast } from "@/utils/toast" import fuzzysort from "fuzzysort" import { formatKeybind, parseKeybind, useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { SettingsList } from "./settings-list" +import { SettingsListV2 } from "./settings-v2/parts/list" -const ButtonV2 = lazy(() => import("@opencode-ai/ui/v2/button-v2").then((module) => ({ default: module.ButtonV2 }))) const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon }))) -const IconButtonV2 = lazy(() => - import("@opencode-ai/ui/v2/icon-button-v2").then((module) => ({ default: module.IconButtonV2 })), -) -const TextInputV2 = lazy(() => - import("@opencode-ai/ui/v2/text-input-v2").then((module) => ({ default: module.TextInputV2 })), -) -const SettingsListV2 = lazy(() => - import("./settings-v2/parts/list").then((module) => ({ default: module.SettingsListV2 })), -) const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const PALETTE_ID = "command.palette" diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index aee4fc9896ff..af24a47274fa 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -1,4 +1,4 @@ -import { Component } from "solid-js" +import { Component, createSignal, startTransition } from "solid-js" import { Dialog } from "@opencode-ai/ui/v2/dialog-v2" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" import { Icon } from "@opencode-ai/ui/icon" @@ -19,6 +19,7 @@ export const DialogSettings: Component<{ const language = useLanguage() const platform = usePlatform() const dialog = useDialog() + const [tab, setTab] = createSignal(props.defaultValue ?? "general") const showProviders = () => { void dialog.show(() => ) @@ -29,7 +30,8 @@ export const DialogSettings: Component<{ void startTransition(() => setTab(value))} class="settings-v2" > From 7f57d2a9acabc5a0425c93e4e217515076037599 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:12:53 +0800 Subject: [PATCH 09/19] feat(app): show draft server status in titlebar (#35521) --- .../src/components/session/session-header.tsx | 4 ++-- packages/app/src/components/titlebar.tsx | 8 +++++++- packages/app/src/pages/new-session.tsx | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 4ec225c94225..500cce7def13 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -31,6 +31,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { reviewTooltipKeybind } from "../command-tooltip-keybind" +import { useTitlebarRightMount } from "../titlebar" const OPEN_APPS = [ "vscode", @@ -284,10 +285,9 @@ export function SessionHeader() { } const [centerMount, setCenterMount] = createSignal(null) - const [rightMount, setRightMount] = createSignal(null) + const rightMount = useTitlebarRightMount() onMount(() => { setCenterMount(document.getElementById("opencode-titlebar-center")) - setRightMount(document.getElementById("opencode-titlebar-right")) }) return ( diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 3592bbe6227f..8706fd93c904 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createResource, createSignal, Match, Show, Switch, untrack } from "solid-js" +import { createEffect, createMemo, createResource, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js" import { createStore } from "solid-js/store" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -60,6 +60,12 @@ export type TitlebarUpdate = { install: () => void } +export function useTitlebarRightMount() { + const [mount, setMount] = createSignal(null) + onMount(() => setMount(document.getElementById("opencode-titlebar-right"))) + return mount +} + export function Titlebar(props: { update?: TitlebarUpdate }) { const layout = useLayout() const platform = usePlatform() diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index af53ca3d4c23..fdb9929eca58 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -1,8 +1,11 @@ import { Show, createEffect, createMemo, createResource, untrack } from "solid-js" import { createStore } from "solid-js/store" +import { Portal } from "solid-js/web" import { useSearchParams } from "@solidjs/router" +import { Tooltip } from "@opencode-ai/ui/tooltip" import { NewSessionDesignView } from "@/components/session" import { PromptInput } from "@/components/prompt-input" +import { StatusPopoverV2 } from "@/components/status-popover" import { useSettingsCommand } from "@/components/settings-dialog" import { PromptProjectAddButton, @@ -15,11 +18,13 @@ import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" +import { useSettings } from "@/context/settings" import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer" import { useSessionKey } from "@/pages/session/session-layout" import { useComposerCommands } from "@/pages/session/use-composer-commands" import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" +import { useTitlebarRightMount } from "@/components/titlebar" const showWorkspaceBar = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" @@ -35,6 +40,7 @@ export default function NewSessionPage() { const serverSync = useServerSync() const comments = useComments() const language = useLanguage() + const settings = useSettings() const route = useSessionKey() const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() @@ -55,6 +61,7 @@ export default function NewSessionPage() { }) const [store, setStore] = createStore<{ worktree?: string }>({}) + const rightMount = useTitlebarRightMount() const newSessionWorktree = createMemo(() => { if (store.worktree) return store.worktree @@ -92,6 +99,17 @@ export default function NewSessionPage() { return (
+ + {(mount) => ( + + + + + + + + )} +
From dffecb6478d4cc20caa9ca0a1b245cce880b3b0c Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:20:15 +0800 Subject: [PATCH 10/19] fix(desktop): gate first launch onboarding (#34930) --- packages/app/src/app.tsx | 87 ++++++++++++-------- packages/app/src/context/server.tsx | 2 +- packages/app/src/index.ts | 6 ++ packages/app/src/pages/new-session.tsx | 14 ++-- packages/desktop/src/main/index.ts | 3 + packages/desktop/src/main/ipc.ts | 6 ++ packages/desktop/src/main/onboarding.ts | 28 +++++++ packages/desktop/src/main/store-keys.ts | 1 + packages/desktop/src/preload/index.ts | 3 + packages/desktop/src/preload/types.ts | 2 + packages/desktop/src/renderer/index.tsx | 16 +++- packages/desktop/src/renderer/onboarding.tsx | 79 ++++++++++++++++++ 12 files changed, 204 insertions(+), 43 deletions(-) create mode 100644 packages/desktop/src/main/onboarding.ts create mode 100644 packages/desktop/src/renderer/onboarding.tsx diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index c7cdc73f9b3d..9c2e7d6a1f0e 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -126,10 +126,10 @@ function SelectedServerProviders(props: ParentProps) { ) } -function LegacyServerLayout(props: ParentProps) { +function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { return ( - {props.children} + {props.children} ) } @@ -263,12 +263,14 @@ function DesktopCommands() { type ServerScopedShellProps = ParentProps<{ directory?: () => string | undefined sessionID?: () => string | undefined + serverScoped?: JSX.Element }> function ServerScopedProviders(props: ServerScopedShellProps) { return ( + {props.serverScoped} {props.children} @@ -277,16 +279,16 @@ function ServerScopedProviders(props: ServerScopedShellProps) { function LegacyServerScopedShell(props: ServerScopedShellProps) { return ( - + {props.children} ) } -function NewAppLayout(props: ParentProps) { +function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { return ( - + {props.children} @@ -347,7 +349,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { ) } -function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { +function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise }>) { const server = useServer() const checkServerHealth = useCheckServerHealth() @@ -376,34 +378,45 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { const checking = createMemo( () => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state), ) + const [startup] = createResource(async () => { + if (!props.startup) return true + await props.startup.catch((error) => { + console.error("[startup] startup gate failed", error) + }) + return true + }) + const startupChecking = createMemo( + () => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state), + ) + const loading = createMemo(() => checking() || startupChecking()) return ( - + <> + + { + if (checkMode() === "background") void healthCheckActions.refetch() + }} + onServerSelected={(key) => { + setCheckMode("blocking") + server.setActive(key) + void healthCheckActions.refetch() + }} + /> + } + > + {props.children} + + + +
- } - > - { - if (checkMode() === "background") void healthCheckActions.refetch() - }} - onServerSelected={(key) => { - setCheckMode("blocking") - server.setActive(key) - void healthCheckActions.refetch() - }} - /> - } - > - {props.children} -
+ ) } @@ -470,6 +483,8 @@ export function AppInterface(props: { servers?: Array router?: Component disableHealthCheck?: boolean + startup?: Promise + serverScoped?: JSX.Element }) { // The visual new layout lives in the router root so it remains mounted across // route changes. Draft and session routes override only their server-bound data @@ -491,7 +506,7 @@ export function AppInterface(props: { > - + - {routerProps.children} + {routerProps.children} )} > - + @@ -517,12 +532,16 @@ export function AppInterface(props: { ) } -function Routes() { +function Routes(props: { serverScoped?: JSX.Element }) { const settings = useSettings() return ( <> - + ( + {routeProps.children} + )} + > {} } /> diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 95c6940d70c5..960617f443aa 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -312,7 +312,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }) } - const isReady = createMemo(() => ready() && !!state.active) + const isReady = Object.assign(createMemo(() => ready() && !!state.active), { promise: ready.promise }) const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer) const projects = createServerProjects({ scope, store, setStore }) diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 2bc9262fde63..ced001ceccf9 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,4 +1,10 @@ export { AppBaseProviders, AppInterface } from "./app" +export { useLayout } from "./context/layout" +export { useServerSDK } from "./context/server-sdk" +export { useServerSync } from "./context/server-sync" +export { useServer } from "./context/server" +export { useTabs } from "./context/tabs" +export { useProviders } from "./hooks/use-providers" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index fdb9929eca58..461be60450b4 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -26,7 +26,7 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" import { useTitlebarRightMount } from "@/components/titlebar" -const showWorkspaceBar = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" +const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" /** * The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt @@ -63,7 +63,9 @@ export default function NewSessionPage() { const [store, setStore] = createStore<{ worktree?: string }>({}) const rightMount = useTitlebarRightMount() + const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git") const newSessionWorktree = createMemo(() => { + if (!showWorkspaceBar()) return "main" if (store.worktree) return store.worktree const project = sync().project if (project && sdk().directory !== project.worktree) return sdk().directory @@ -123,7 +125,7 @@ export default function NewSessionPage() {
} > -
+
- + pendingDeepLinks.splice(0), getDefaultServerUrl: () => getDefaultServerUrl(), setDefaultServerUrl: (url) => setDefaultServerUrl(url), + isFirstLaunchOnboardingPending, + finishFirstLaunchOnboarding, getDisplayBackend: async () => null, setDisplayBackend: async () => undefined, parseMarkdown: async (markdown) => parseMarkdown(markdown), diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 22560ff7da3a..073d288e5cf0 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -27,6 +27,8 @@ type Deps = { consumeInitialDeepLinks: () => Promise | string[] getDefaultServerUrl: () => Promise | string | null setDefaultServerUrl: (url: string | null) => Promise | void + isFirstLaunchOnboardingPending: () => Promise | boolean + finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise | string | null getDisplayBackend: () => Promise setDisplayBackend: (backend: string | null) => Promise | void parseMarkdown: (markdown: string) => Promise | string @@ -50,6 +52,10 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => deps.setDefaultServerUrl(url), ) + ipcMain.handle("is-first-launch-onboarding-pending", () => deps.isFirstLaunchOnboardingPending()) + ipcMain.handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) => + deps.finishFirstLaunchOnboarding(createDefaultProject), + ) ipcMain.handle("get-display-backend", () => deps.getDisplayBackend()) ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) => deps.setDisplayBackend(backend), diff --git a/packages/desktop/src/main/onboarding.ts b/packages/desktop/src/main/onboarding.ts new file mode 100644 index 000000000000..926506e7b9ba --- /dev/null +++ b/packages/desktop/src/main/onboarding.ts @@ -0,0 +1,28 @@ +import { mkdir } from "node:fs/promises" +import { join } from "node:path" +import { app } from "electron" +import { getStore } from "./store" +import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "./store-keys" +import { write as writeLog } from "./logging" + +const DEFAULT_PROJECT_DIR = "New OpenCode Project" + +export function isFirstLaunchOnboardingPending() { + const pending = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) !== true + writeLog("onboarding", "first launch onboarding pending checked", { pending }) + return pending +} + +export async function finishFirstLaunchOnboarding(createDefaultProject: boolean) { + if (!isFirstLaunchOnboardingPending()) { + writeLog("onboarding", "first launch onboarding already completed") + return null + } + + const defaultProject = createDefaultProject ? join(app.getPath("documents"), DEFAULT_PROJECT_DIR) : null + if (defaultProject) await mkdir(defaultProject, { recursive: true }) + + getStore().set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, true) + writeLog("onboarding", "first launch onboarding completed", { createDefaultProject, defaultProject }) + return defaultProject +} diff --git a/packages/desktop/src/main/store-keys.ts b/packages/desktop/src/main/store-keys.ts index 270ffe7504e0..506924b74a66 100644 --- a/packages/desktop/src/main/store-keys.ts +++ b/packages/desktop/src/main/store-keys.ts @@ -1,5 +1,6 @@ export const SETTINGS_STORE = "opencode.settings" export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" +export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete" export const WSL_SERVERS_KEY = "wslServers" export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled" export const WINDOW_IDS_KEY = "windowIds" diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 2cb8a9ee073c..47a757a557d3 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -59,6 +59,9 @@ const api: ElectronAPI = { consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"), getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"), setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url), + isFirstLaunchOnboardingPending: () => ipcRenderer.invoke("is-first-launch-onboarding-pending"), + finishFirstLaunchOnboarding: (createDefaultProject) => + ipcRenderer.invoke("finish-first-launch-onboarding", createDefaultProject), getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"), setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend), parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 5401c2070d7a..d7509580ad05 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -49,6 +49,8 @@ export type ElectronAPI = { consumeInitialDeepLinks: () => Promise getDefaultServerUrl: () => Promise setDefaultServerUrl: (url: string | null) => Promise + isFirstLaunchOnboardingPending: () => Promise + finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise getDisplayBackend: () => Promise setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise parseMarkdownCommand: (markdown: string) => Promise diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 5369ab21e310..966fb0c0bf3a 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -23,6 +23,7 @@ import { render } from "solid-js/web" import pkg from "../../package.json" import { initI18n, t } from "./i18n" import { initializationData, initializationReady } from "./initialization" +import { DesktopFirstLaunchOnboarding } from "./onboarding" import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" import { availableStartupServer, readyWslConnections } from "./wsl/connections" import "./styles.css" @@ -347,6 +348,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { const router = (props: BaseRouterProps) => ( ) + const onboarding = Promise.withResolvers() function handleClick(e: MouseEvent) { const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null @@ -400,12 +402,22 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { const effectiveDefaultServer = createMemo(() => ServerConnection.Key.make(availableStartupServer(defaultServer.latest, wslServers.data)), ) - return ( }> {(key) => ( - + + } + > )} diff --git a/packages/desktop/src/renderer/onboarding.tsx b/packages/desktop/src/renderer/onboarding.tsx new file mode 100644 index 000000000000..47758bc78869 --- /dev/null +++ b/packages/desktop/src/renderer/onboarding.tsx @@ -0,0 +1,79 @@ +import { + ServerConnection, + useLayout, + useProviders, + useServer, + useServerSDK, + useServerSync, + useTabs, +} from "@opencode-ai/app" +import { onMount, startTransition } from "solid-js" + +export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoaded: () => void }) { + const server = useServer() + const serverSDK = useServerSDK() + const serverSync = useServerSync() + const layout = useLayout() + const providers = useProviders() + const tabs = useTabs() + + onMount(() => { + void runFirstLaunchOnboarding().finally(props.onLoaded) + }) + + async function runFirstLaunchOnboarding() { + try { + await Promise.all( + [server.ready.promise, layout.ready.promise, tabs.ready.promise, tabs.recentReady.promise].map( + (p) => p ?? Promise.resolve(), + ), + ) + if (!server.isLocal()) return + + const pending = await window.api.isFirstLaunchOnboardingPending() + if (!pending) return + + const sessions = await serverSDK() + .client.session.list() + .then((x) => x.data ?? []) + .catch(() => undefined) + const connectedProviders = providers.connected() + const paidProviders = providers.paid() + const persistedProjects = layout.projects.list() + const shouldTrigger = + props.initialUrl === "/" && + sessions?.length === 0 && + paidProviders.length === 0 && + persistedProjects.length === 0 && + tabs.store.length === 0 && + server.list.every(ServerConnection.builtin) + + console.info("[desktop-onboarding] first launch onboarding evaluated", { + pending, + shouldTrigger, + initialUrl: props.initialUrl, + sessions: sessions?.length, + connectedProviders: connectedProviders.length, + paidProviders: paidProviders.length, + serverProjects: serverSync().data.project.length, + persistedProjects: persistedProjects.length, + tabs: tabs.store.length, + servers: server.list.map(ServerConnection.key), + }) + + const directory = await window.api.finishFirstLaunchOnboarding(shouldTrigger) + if (!shouldTrigger || !directory) return + + console.info("[desktop-onboarding] starting first launch draft", { directory }) + server.projects.open(directory) + server.projects.touch(directory) + await startTransition(() => { + tabs.newDraft({ server: server.key, directory }) + }) + } catch (error) { + console.error("[desktop-onboarding] first launch onboarding failed", error) + } + } + + return null +} From 977a40af686ced65d14e86d52ca41c9137b7e515 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 6 Jul 2026 08:21:25 +0000 Subject: [PATCH 11/19] chore: generate --- packages/app/src/context/server.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 960617f443aa..450129f45884 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -312,7 +312,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }) } - const isReady = Object.assign(createMemo(() => ready() && !!state.active), { promise: ready.promise }) + const isReady = Object.assign( + createMemo(() => ready() && !!state.active), + { promise: ready.promise }, + ) const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer) const projects = createServerProjects({ scope, store, setStore }) From b0e41ff2c4069bafdaac9c11327659b1bf911f87 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:37:50 +0800 Subject: [PATCH 12/19] fix(app): use selected home project for new sessions (#35530) --- packages/app/src/components/titlebar.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 8706fd93c904..2a818d565da1 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -334,6 +334,21 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { return } + if (route.type === "home") { + const selection = layout.home.selection() + const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server) + const project = conn + ? global + .ensureServerCtx(conn) + .projects.list() + .find((item) => item.worktree === selection.directory) + : undefined + if (conn && project) { + tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "") + return + } + } + const current = layout.projects.list()[0] if (current) { tabs.newDraft({ server: server.key, directory: current.worktree }, "") From 561070fbc24eaf4c691ef69e8a1fa07c5dab0f87 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 6 Jul 2026 13:01:08 +0200 Subject: [PATCH 13/19] deps: upgrade OpenTUI to v0.4.3 (#35226) --- bun.lock | 50 ++++++++++++------- package.json | 6 +-- packages/plugin/package.json | 6 +-- .../tui/src/component/dialog-provider.tsx | 6 +-- packages/tui/src/routes/session/index.tsx | 2 +- packages/tui/src/ui/dialog-prompt.tsx | 2 +- 6 files changed, 43 insertions(+), 29 deletions(-) diff --git a/bun.lock b/bun.lock index 742578ad2b25..49f4ff3d7a96 100644 --- a/bun.lock +++ b/bun.lock @@ -704,9 +704,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.3.4", - "@opentui/keymap": ">=0.3.4", - "@opentui/solid": ">=0.3.4", + "@opentui/core": ">=0.4.3", + "@opentui/keymap": ">=0.4.3", + "@opentui/solid": ">=0.4.3", }, "optionalPeers": [ "@opentui/core", @@ -1096,9 +1096,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.3.4", - "@opentui/keymap": "0.3.4", - "@opentui/solid": "0.3.4", + "@opentui/core": "0.4.3", + "@opentui/keymap": "0.4.3", + "@opentui/solid": "0.4.3", "@pierre/diffs": "1.2.10", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -2031,27 +2031,27 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="], + "@opentui/core": ["@opentui/core@0.4.3", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.3", "@opentui/core-darwin-x64": "0.4.3", "@opentui/core-linux-arm64": "0.4.3", "@opentui/core-linux-arm64-musl": "0.4.3", "@opentui/core-linux-x64": "0.4.3", "@opentui/core-linux-x64-musl": "0.4.3", "@opentui/core-win32-arm64": "0.4.3", "@opentui/core-win32-x64": "0.4.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-rrJfAk13tALDqldYjhc78eWQ+aKq1iknJgffIOg3OwyZoqQo+p6gtuqyhmWvXIfQzlNUbpgpCPcxbXlhMnlaHQ=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p5+7AAxpxGuDGagyQfewKtmTFnN7THvTVY4FyKqUtJomNaHdQXPHztapNNzMx0DGWbwOUbVKzpL+yc3CZY3chQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+fh0vEUE0lwVC7RW5ijYLRlTLp5NfvCRj8SzxDVd7IL2j2ssB6YXcfIbXq2EW7UGnrejwPRXf1tgUrIXW9KmOw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gl6qA5QJy6u8Cbt7gOtHbhhfMZ4qQDb0kEwFXHcMGmbnKzz4OHoq74D6tNjyvSQB9saoC7C6C0tvn2DcJOuNog=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8p8g8/AEq/xFGpQ7XcIFKcAqjc0QwsZcv+Ll9RbCDpUA56FGH6jfLDir0KYTNTgYXJTIrBIENI9K46VuxMUMQA=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dXpJitiZdYE3hq2Pvx6e9I0uPQSOcnaLLp1pDgWAHv+3kvKSHEX//9Yr/pV/Ua6qqT7p+2D/K4vXNap/NKVo2w=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/QiFpCrpU2O7vy8QYmLIQYbvAtKDgmqcVjR7dGtqSzkiQk3ktNJoo5RozG7ueXnjung1Wp0nKldKxo2Csg/OrA=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mx2zuOjrhm/z2SDS6RExIyjP/SnN/8QhhagxURUw0jQi/NssGSeAllu1cBAFFnhobJL5QLTE4FU4CRhUK9svgg=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NuoqvWKGXaYnmlqvu7Gg2lLI6yVMnS9OfWBvxp+7Q+McSgHFSTQmYBXaPpvQ8HikpQXE1nCeMPtuSG4PdZHe2w=="], - "@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="], + "@opentui/keymap": ["@opentui/keymap@0.4.3", "", { "dependencies": { "@opentui/core": "0.4.3" }, "peerDependencies": { "@opentui/react": "0.4.3", "@opentui/solid": "0.4.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-sinX0pyQBRrEvo89PSSUbSUDIYpL3xWo81VEfec58VFoVRB5FG48/deAtvRTQfJ8w1kgbzN8hzdOXdSm61zBmw=="], - "@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="], + "@opentui/solid": ["@opentui/solid@0.4.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-RcV0+S8HMdXOASyr7HmJUBuTUIaFPzAxMDa44VftS5C2JUgrmAuWo0Njv1q3TWRB1owjHnyKhEfWGKq7A82wxw=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -3243,7 +3243,7 @@ "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], - "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], @@ -6383,6 +6383,8 @@ "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], @@ -7091,6 +7093,17 @@ "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="], + + + + + + + + + + + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -7425,6 +7438,7 @@ "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], diff --git a/package.json b/package.json index fd86ad3decd2..e5526b22ffb1 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,9 @@ "@octokit/rest": "22.0.0", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.3.4", - "@opentui/keymap": "0.3.4", - "@opentui/solid": "0.3.4", + "@opentui/core": "0.4.3", + "@opentui/keymap": "0.4.3", + "@opentui/solid": "0.4.3", "@tanstack/solid-virtual": "3.13.28", "@shikijs/stream": "4.2.0", "ulid": "3.0.1", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 50aeffb8e10a..1b16375c032e 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -27,9 +27,9 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.3.4", - "@opentui/keymap": ">=0.3.4", - "@opentui/solid": ">=0.3.4" + "@opentui/core": ">=0.4.3", + "@opentui/keymap": ">=0.4.3", + "@opentui/solid": ">=0.4.3" }, "peerDependenciesMeta": { "@opentui/core": { diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index 2767508eb2d2..0fd51e3c1c71 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -366,8 +366,8 @@ function ApiMethod(props: ApiMethodProps) { + ({ opencode: ( @@ -390,7 +390,7 @@ function ApiMethod(props: ApiMethodProps) { ), - }[props.providerID] ?? undefined + })[props.providerID] ?? undefined } onConfirm={async (value) => { if (!value) return diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6df6b00d2d11..6d77b0ea58fd 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1528,7 +1528,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las customBorderChars={SplitBorder.customBorderChars} borderColor={theme.error} > - {props.message.error?.data.message} + {errorMessage(props.message.error)} diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index d627ea2970d9..f518fb2950b7 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/packages/tui/src/ui/dialog-prompt.tsx @@ -83,7 +83,7 @@ export function DialogPrompt(props: DialogPromptProps) { - {props.description} + {props.description?.()}