Compare commits

..

1 Commits

Author SHA1 Message Date
vprdev 3802736137 docs: add architecture review 2026-07-10 17:59:13 +00:00
128 changed files with 113546 additions and 172235 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
const TEAM = {
tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"],
inference: ["fwang", "MrMushrooooom", "starptech"],
windows: ["Hona"],
} as const
+175
View File
@@ -0,0 +1,175 @@
# Architecture Review
Status: WIP investigation. This document records candidates, not approved designs.
Reviewed areas:
- V2 Session runtime in `packages/core`
- Client contract generation across Schema, Protocol, Client, and SDK
- Server network and embedded hosting
- CLI background process lifecycle
The review uses the deletion test: a useful module concentrates complexity when removed; a shallow module merely moves its implementation into a caller.
## 1. Deepen Session inbox promotion
Recommendation: **Strong** and the top candidate.
Files:
- `packages/core/src/session/input.ts:151-263`
- `packages/core/src/session/runner/llm.ts:190-198,417-445`
- `packages/core/test/session-prompt.test.ts:123-436`
- `packages/core/test/session-runner.test.ts:1988-2345`
Problem: steer and queue eligibility, ordering, and Prompt Promotion leak into the Session Runner. The runner repeatedly queries pending delivery modes and selects separate promotion operations, so the runner and inbox jointly define one domain policy.
Direction: deepen the Session inbox module so delivery eligibility, ordering, and Prompt Promotion sit behind one seam. Keep durable admission separate from execution.
Benefits:
- Locality: one module owns delivery policy.
- Leverage: tests exercise one seam.
- The runner loses repeated policy branches.
- The documented steer and queue rules remain explicit.
Deletion test: removing the scattered runner branches would concentrate policy in the inbox rather than move it to another caller.
## 2. Complete Step recording in one module
Recommendation: **Strong**, but higher risk than the inbox change.
Files:
- `packages/core/src/session/runner/publish-llm-event.ts:53-406`
- `packages/core/src/session/runner/llm.ts:224-385`
- `packages/core/test/session-runner-tool-events.test.ts:74-136`
- `packages/core/test/session-runner.test.ts:2688-3449`
Problem: the event publisher records stream fragments, but exposes state and terminal mechanics to the runner. The runner inspects publisher state, settles tool fibers, captures snapshots, publishes `Step.Ended`, and calculates the terminal result.
Direction: deepen Step recording so one module owns recording through settlement while the runner retains the required explicit `llm.stream(request)` call.
Benefits:
- Locality: terminal Step rules live together.
- Leverage: interruption and failure cases can use table-driven tests.
- The interface stops exposing internal publisher state.
- Provider, tool, and snapshot settlement stop leaking into orchestration.
Deletion test: the existing publisher already concentrates a substantial state machine. The opportunity is to remove its shallow state-inspection seam, not its implementation.
## 3. Return background lifecycle to CLI
Recommendation: **Strong**.
Files:
- `packages/client/src/effect/service.ts:1-165`
- `packages/client/src/effect/index.ts:20`
- `packages/cli/src/services/service-config.ts:29-49`
- `packages/cli/src/commands/handlers/default.ts:47-77`
- `packages/cli/src/commands/handlers/serve.ts:69-117`
- `packages/cli/test/service.test.ts:10-25`
Problem: Client exports a Node-specific background process implementation containing process spawning, registration files, health checks, signals, version replacement, and authentication. Only CLI modules consume it, and several handlers repeat discover-or-start policy.
This conflicts with the network-only Client architecture recorded in `CONTEXT.md:172-176`.
Direction: concentrate discovery, registration, replacement, authentication, and restart rules in a deep CLI background module. Keep Client focused on network transport.
Benefits:
- The Client interface shrinks.
- Locality: CLI lifecycle policy lives in CLI.
- A real host adapter replaces a hypothetical Client seam.
- Registration and authentication tests gain one target.
Deletion test: removing the Client daemon module improves locality because it has no non-CLI consumer. Removing a deep CLI replacement would scatter the same lifecycle rules across handlers.
## 4. Collapse Promise source surgery
Recommendation: **Worth exploring**.
Files:
- `packages/httpapi-codegen/src/index.ts:257-278`
- `packages/httpapi-codegen/src/index.ts:591-636`
- `packages/httpapi-codegen/src/index.ts:685-710`
- `packages/httpapi-codegen/test/generate.test.ts:344-387,450-479`
Problem: the Promise emitter first renders TypeScript, then rewrites exact text markers to add binary and wildcard behavior. One part of the implementation depends on another part's private output spelling and formatting.
Direction: deepen the Promise emitter so typed transport facts drive rendering directly.
Benefits:
- Locality: rendering decisions meet emitted code.
- The brittle text-replacement seam disappears.
- Existing runtime tests retain leverage.
- Formatting changes stop becoming generation failures.
Deletion test: deleting `normalizePromiseClientContent` and rendering those decisions directly concentrates implementation instead of moving it.
## 5. Make Client naming total
Recommendation: **Strong**.
Files:
- `packages/protocol/src/client.ts:35-60`
- `packages/httpapi-codegen/src/index.ts:96-101`
- `packages/client/src/promise/generated/client.ts:859`
- `packages/client/src/effect/generated/client.ts:1057`
- `packages/client/test/promise.test.ts:4-33`
- `packages/sdk-next/src/opencode.ts:41-48`
Problem: the consumer naming adapter is partial. Missing entries silently fall back to internal Protocol identifiers, currently leaking `server.mcp` through Promise, Effect, and Embedded OpenCode.
Direction: require an explicit consumer name for every public group and fail generation when naming policy is missing.
Benefits:
- Leverage: one naming policy aligns every emitter.
- Public interfaces expose domain names rather than internal identifiers.
- Missing policy fails during generation.
- Promise, Effect, and Embedded OpenCode remain aligned.
Deletion test: removing the naming adapter would leak every internal identifier. The adapter is necessary but should be deepened into a total policy.
## 6. Deepen Server host assembly
Recommendation: **Strong**.
Files:
- `packages/server/src/routes.ts:31-92`
- `packages/cli/src/commands/handlers/serve.ts:134-153`
- `packages/sdk-next/src/opencode.ts:11-44`
- `packages/sdk-next/test/embedded.test.ts:29-209`
Problem: the network and embedded adapters both understand Server implementation details. Core instance sharing, request injection, memoization, and cleanup leak across the host seams.
Direction: retain both adapters because they establish a real seam, but absorb common host assembly and lifecycle implementation into Server.
Benefits:
- Two adapters continue to justify the seam.
- Locality: host lifecycle belongs to Server.
- Network and embedded behavior can share conformance tests.
- Callers stop depending on Core graph internals.
Deletion test: the current route constructors are shallow, while the hidden route implementation is deep. Promote that depth into the host-facing Server module.
## Top Recommendation
Start with Session inbox promotion. It removes repeated domain policy from the hottest orchestration path, has a smaller migration surface than Step recording, and creates a high-leverage test seam for steer and queue behavior.
Before implementation, settle the desired inbox interface and verify these invariants:
- Durable prompt admission remains separate from execution.
- A blocked first Step leaves pending inputs untouched.
- Steers promote at safe Step boundaries while continuation is required.
- One queued prompt promotes when the Session would otherwise become idle.
- Promoting new user input resets the selected agent's Step allowance.
- The runner keeps one explicit `llm.stream(request)` call per Physical Attempt.
+35 -47
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -95,7 +95,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.17.19",
"version": "1.17.18",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -143,7 +143,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -157,7 +157,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -193,7 +193,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -220,7 +220,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -242,7 +242,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -266,7 +266,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -286,7 +286,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.17.19",
"version": "1.17.18",
"bin": {
"opencode": "./bin/opencode",
},
@@ -303,7 +303,7 @@
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
@@ -338,7 +338,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.0",
"gitlab-ai-provider": "6.10.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -380,7 +380,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -434,7 +434,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -448,7 +448,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"effect": "catalog:",
},
@@ -460,7 +460,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -492,7 +492,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -508,7 +508,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -539,7 +539,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -558,7 +558,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.19",
"version": "1.17.18",
"bin": {
"opencode": "./bin/opencode",
},
@@ -578,7 +578,7 @@
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
@@ -631,7 +631,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.0",
"gitlab-ai-provider": "6.10.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -689,7 +689,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -765,7 +765,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -780,7 +780,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -795,7 +795,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -839,7 +839,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -852,7 +852,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
@@ -885,7 +885,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -904,7 +904,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -945,7 +945,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -972,7 +972,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1023,7 +1023,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -3850,7 +3850,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.11.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-/LTD5qtSbGGnWd7/HB72YioSMNYqF7RlABojBqRqZ8UK5DpxoTp/dnT7K/tIScDWMYPGpJqX/B7JIOh0xeXFLQ=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.10.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -6020,7 +6020,7 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -6388,7 +6388,7 @@
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -6896,10 +6896,6 @@
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
@@ -7122,10 +7118,6 @@
"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=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"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=="],
@@ -7374,8 +7366,6 @@
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
@@ -7462,8 +7452,6 @@
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"opencode/@ai-sdk/openai/@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=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-QlwoEmN1jiw9TqQgOutPOv4mEMCNUR0Mm3ivYIWlc1o=",
"aarch64-linux": "sha256-KS8unXRXev29zbPskvp2ddtfAKXJN8VfInXnmPqypBI=",
"aarch64-darwin": "sha256-T3tgbuodPitCx2U5JJIQFLkRguAFeiMM7flTsF2H5Iw=",
"x86_64-darwin": "sha256-aITMNkQpFYAChDz5jZCchFkSylsDhJJQoyPu9k23Hl8="
"x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=",
"aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=",
"aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=",
"x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk="
}
}
@@ -1,150 +0,0 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/FileBrowserSidebar"
const projectID = "proj_file_browser_sidebar"
const sessionID = "ses_file_browser_sidebar"
const title = "File browser sidebar"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`)
// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable.
const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// The file-browser sidebar must stay mounted across preview/pinned file-tab
// switches. Remounting resets scroll and filter state.
test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => {
await setup(page)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
await expect(sidebar).toBeVisible()
await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible()
await panel.getByRole("button", { name: "file-00.ts" }).click()
await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible()
const viewport = panel.locator('[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)
const scrolled = await viewport.evaluate((element) => element.scrollTop)
expect(scrolled).toBeGreaterThan(0)
await writeProbe(page)
await panel.getByRole("button", { name: "file-79.ts" }).click()
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible()
expect(await readProbe(page)).toBe(PROBE)
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
await panel.getByRole("button", { name: "file-78.ts" }).dblclick()
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "file-79.ts" }).click()
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
await panel.getByRole("tab", { name: "file-78.ts" }).click()
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
expect(await readProbe(page)).toBe(PROBE)
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
})
type Probed = HTMLElement & { __e2eProbe?: string }
async function writeProbe(page: Page) {
await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => {
;(el as Probed).__e2eProbe = probe
}, PROBE)
}
async function readProbe(page: Page) {
return page
.locator('#review-panel [data-component="session-review-v2-sidebar-root"]')
.evaluate((el) => (el as Probed).__e2eProbe)
}
async function setup(page: Page) {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "file-browser-sidebar",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: sessionID,
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [],
fileList: (path) => {
if (path) return []
return files.map((name) => ({
name,
path: name,
absolute: `${directory}/${name}`,
type: "file" as const,
ignored: false,
}))
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
)
}
@@ -91,17 +91,15 @@ test("opens and searches project files inline", async ({ page }) => {
const panel = page.locator("#review-panel")
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled()
await expect(sidebar).toBeVisible()
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toBeHidden()
await expect(sidebar).toHaveCount(0)
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })
await expect(filter).toBeFocused()
@@ -110,7 +108,6 @@ test("opens and searches project files inline", async ({ page }) => {
await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
@@ -125,17 +122,12 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled()
await panel.getByRole("tab", { name: /Review/ }).click()
await expect(sidebarToggle).toBeEnabled()
await panel.getByRole("tab", { name: "Open file" }).click()
await page.keyboard.press("Control+w")
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
@@ -28,8 +28,8 @@ 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.locator("#session-side-panel-review-tab")
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
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"]')
@@ -111,7 +111,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
await expectTree(page, 8, "git-0.ts")
await selectMode(page, "Git changes", "Branch changes")
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
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_773, "action.yml")
@@ -152,10 +152,7 @@ test.describe("session timeline projection", () => {
parentID: "msg_2000_diff_next_user",
created: 1700000011000,
})
await setupTimeline(page, {
messages: [user, assistantMessage(), nextUser, nextAssistant],
settings: { newLayoutDesigns: false },
})
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.19",
"version": "1.17.18",
"description": "",
"type": "module",
"exports": {
@@ -1,156 +0,0 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Field } from "@opencode-ai/ui/v2/field-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { createEditProjectModel } from "./edit-project"
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
const language = useLanguage()
const model = createEditProjectModel(props)
return (
<Dialog fit>
<form onSubmit={model.submit} class="contents">
<DialogHeader>
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
<Field>
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
<TextInputV2
autofocus
appearance="large"
class="!w-full"
value={model.store.name}
placeholder={model.folderName()}
onInput={(event) => model.setStore("name", event.currentTarget.value)}
/>
</Field>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.icon")}
</div>
<div class="flex items-center gap-3">
<button
type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
}}
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
src={getProjectAvatarSource(props.project.id, {
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
})}
variant={getProjectAvatarVariant(model.store.color)}
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
/>
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
classList={{
"opacity-100": model.store.iconHover,
"opacity-0": !model.store.iconHover,
}}
>
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
</span>
</button>
<input
ref={(element) => {
model.setIconInput(element)
}}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
/>
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
</div>
<Show when={!model.store.iconOverride}>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.color")}
</div>
<div class="-ml-1 flex gap-1.5">
<For each={PROJECT_AVATAR_VARIANTS}>
{(color) => (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
getProjectAvatarVariant(model.store.color) === color,
}}
onClick={() => {
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
model.setStore(
"color",
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
)
}}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
variant={getProjectAvatarVariant(color)}
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)}
</For>
</div>
</div>
</Show>
<Field>
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
<TextareaV2
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={3}
value={model.store.startup}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
spellcheck={false}
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
/>
</Field>
</DialogBody>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</ButtonV2>
</DialogFooter>
</form>
</Dialog>
)
}
@@ -1,32 +1,123 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon"
import { For, Show } from "solid-js"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/core/util/path"
import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerConnection } from "@/context/server"
import { createEditProjectModel } from "./edit-project"
import { useGlobal } from "@/context/global"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
const dialog = useDialog()
const global = useGlobal()
const language = useLanguage()
const model = createEditProjectModel(props)
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
const serverSDK = () => serverCtx().sdk
const serverSync = () => serverCtx().sync
const folderName = createMemo(() => getFilename(props.project.worktree))
const defaultName = createMemo(() => props.project.name || folderName())
const [store, setStore] = createStore({
name: defaultName(),
color: props.project.icon?.color,
iconOverride: props.project.icon?.override,
startup: props.project.commands?.start ?? "",
dragOver: false,
iconHover: false,
})
let iconInput: HTMLInputElement | undefined
function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
setStore("iconOverride", e.target?.result as string)
setStore("iconHover", false)
}
reader.readAsDataURL(file)
}
function handleDrop(e: DragEvent) {
e.preventDefault()
setStore("dragOver", false)
const file = e.dataTransfer?.files[0]
if (file) handleFileSelect(file)
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
setStore("dragOver", true)
}
function handleDragLeave() {
setStore("dragOver", false)
}
function handleInputChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (file) handleFileSelect(file)
}
function clearIcon() {
setStore("iconOverride", "")
}
const saveMutation = useMutation(() => ({
mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await serverSDK().client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
serverSync().project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
dialog.close()
},
}))
function handleSubmit(e: SubmitEvent) {
e.preventDefault()
if (saveMutation.isPending) return
saveMutation.mutate()
}
return (
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
<div class="flex flex-col gap-4">
<TextField
autofocus
type="text"
label={language.t("dialog.project.edit.name")}
placeholder={model.folderName()}
value={model.store.name}
onChange={(v) => model.setStore("name", v)}
placeholder={folderName()}
value={store.name}
onChange={(v) => setStore("name", v)}
/>
<div class="flex flex-col gap-2">
@@ -34,32 +125,38 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<div class="flex gap-3 items-start">
<div
class="relative"
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
onMouseEnter={() => setStore("iconHover", true)}
onMouseLeave={() => setStore("iconHover", false)}
>
<div
class="relative size-16 rounded-md transition-colors cursor-pointer"
classList={{
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
"border-border-base hover:border-border-strong": !model.store.dragOver,
"overflow-hidden": !!model.store.iconOverride,
"border-text-interactive-base bg-surface-info-base/20": store.dragOver,
"border-border-base hover:border-border-strong": !store.dragOver,
"overflow-hidden": !!store.iconOverride,
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => {
if (store.iconOverride && store.iconHover) {
clearIcon()
} else {
iconInput?.click()
}
}}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<Show
when={getProjectAvatarSource(props.project.id, {
color: model.store.color,
color: store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
override: store.iconOverride,
})}
fallback={
<div class="size-full flex items-center justify-center">
<Avatar
fallback={model.store.name || model.defaultName()}
{...getAvatarColors(model.store.color)}
fallback={store.name || defaultName()}
{...getAvatarColors(store.color)}
class="size-full text-[32px]"
/>
</div>
@@ -77,8 +174,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": model.store.iconHover && !model.store.iconOverride,
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
"opacity-100": store.iconHover && !store.iconOverride,
"opacity-0": !(store.iconHover && !store.iconOverride),
}}
>
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
@@ -86,8 +183,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
"opacity-100": store.iconHover && !!store.iconOverride,
"opacity-0": !(store.iconHover && !!store.iconOverride),
}}
>
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
@@ -96,12 +193,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<input
id="icon-upload"
ref={(el) => {
model.setIconInput(el)
iconInput = el
}}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
onChange={handleInputChange}
/>
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
@@ -110,7 +207,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
</div>
</div>
<Show when={!model.store.iconOverride}>
<Show when={!store.iconOverride}>
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
<div class="flex gap-1.5">
@@ -119,21 +216,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={model.store.color === color}
aria-pressed={store.color === color}
classList={{
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
model.store.color === color,
store.color === color,
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
model.store.color !== color,
store.color !== color,
}}
onClick={() => {
if (model.store.color === color && !props.project.icon?.url) return
model.setStore("color", model.store.color === color ? undefined : color)
if (store.color === color && !props.project.icon?.url) return
setStore("color", store.color === color ? undefined : color)
}}
>
<Avatar
fallback={model.store.name || model.defaultName()}
fallback={store.name || defaultName()}
{...getAvatarColors(color)}
class="size-full rounded"
/>
@@ -149,19 +246,19 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
label={language.t("dialog.project.edit.worktree.startup")}
description={language.t("dialog.project.edit.worktree.startup.description")}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
value={model.store.startup}
onChange={(v) => model.setStore("startup", v)}
value={store.startup}
onChange={(v) => setStore("startup", v)}
spellcheck={false}
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
/>
</div>
<div class="flex justify-end gap-2">
<Button type="button" variant="ghost" size="large" onClick={model.close}>
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
</Button>
</div>
</form>
-119
View File
@@ -1,119 +0,0 @@
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global"
import { type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const dialog = useDialog()
const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
const folderName = createMemo(() => getFilename(props.project.worktree))
const defaultName = createMemo(() => props.project.name || folderName())
const [store, setStore] = createStore({
name: defaultName(),
color: props.project.icon?.color,
iconOverride: props.project.icon?.override,
startup: props.project.commands?.start ?? "",
dragOver: false,
iconHover: false,
})
let iconInput: HTMLInputElement | undefined
function selectFile(file: File) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (event) => {
const result = event.target?.result
if (typeof result !== "string") return
setStore("iconOverride", result)
setStore("iconHover", false)
}
reader.readAsDataURL(file)
}
function drop(event: DragEvent) {
event.preventDefault()
setStore("dragOver", false)
const file = event.dataTransfer?.files[0]
if (file) selectFile(file)
}
function dragOver(event: DragEvent) {
event.preventDefault()
setStore("dragOver", true)
}
function dragLeave() {
setStore("dragOver", false)
}
function inputChange(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0]
if (file) selectFile(file)
}
function iconClick() {
if (store.iconOverride && store.iconHover) {
setStore("iconOverride", "")
return
}
iconInput?.click()
}
const save = useMutation(() => ({
mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await serverCtx().sdk.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
serverCtx().sync.project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
dialog.close()
},
}))
function submit(event: SubmitEvent) {
event.preventDefault()
if (save.isPending) return
save.mutate()
}
return {
store,
setStore,
folderName,
defaultName,
save,
submit,
drop,
dragOver,
dragLeave,
inputChange,
iconClick,
close() {
dialog.close()
},
setIconInput(input: HTMLInputElement) {
iconInput = input
},
}
}
@@ -1,9 +1,8 @@
import { describe, expect, test } from "bun:test"
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
import type { FileNode } from "@opencode-ai/sdk/v2"
import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model"
describe("buildFileTreeV2Model", () => {
test("builds a sorted tree and flattens expanded directories", () => {
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)
@@ -19,7 +18,7 @@ describe("buildFileTreeV2Model", () => {
])
})
test("skips children of collapsed directories", () => {
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([
@@ -29,46 +28,19 @@ describe("buildFileTreeV2Model", () => {
])
})
test("normalizes duplicate and messy paths", () => {
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("handles deeply nested paths", () => {
const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.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)
})
})
describe("flattenLiveFileTreeV2", () => {
test("flattens live children using original paths for nested lookups", () => {
const nodes: Record<string, FileNode[]> = {
"": [
{ name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false },
{ name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false },
],
src: [
{ name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false },
{ name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false },
],
"src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }],
}
expect(
flattenLiveFileTreeV2(
(path) => nodes[path] ?? [],
(path) => path === "src",
).map((row) => [row.node.path, row.node.originalPath, row.level]),
).toEqual([
["src", "src", 0],
["src/a.ts", "src/a.ts", 1],
["src/lib", "src/lib", 1],
["README.md", "README.md", 0],
])
})
})
@@ -75,33 +75,3 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin
return rows
}
export function flattenLiveFileTreeV2(
children: (path: string) => readonly FileNode[],
expanded: (path: string) => boolean,
) {
const rows: FileTreeV2Row[] = []
const stack = children("")
.toReversed()
.map((node) => ({ node: toLiveNode(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 nested = children(row.node.originalPath)
for (let index = nested.length - 1; index >= 0; index--) {
stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 })
}
}
return rows
}
function toLiveNode(node: FileNode): FileTreeV2Node {
return {
...node,
path: normalizeFileTreeV2Path(node.path),
originalPath: node.path,
}
}
+29 -60
View File
@@ -16,13 +16,7 @@ import type { FileNode } from "@opencode-ai/sdk/v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import {
buildFileTreeV2Model,
flattenFileTreeV2,
flattenLiveFileTreeV2,
normalizeFileTreeV2Path,
type FileTreeV2Node,
} from "@/components/file-tree-v2-model"
import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
import { virtualScrollElement } from "@/components/virtual-scroll-element"
export type { Kind } from "@/components/file-tree"
@@ -42,7 +36,7 @@ function guideLineLeft(level: number) {
export const kindLabel = (kind: Kind) => {
if (kind === "add") return "A"
if (kind === "del") return "D"
return "M"
return ""
}
export const kindChange = (kind: Kind) => {
@@ -74,7 +68,7 @@ const FileTreeNodeV2 = (
"class",
"classList",
])
const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path))
const kind = () => local.kinds?.get(local.node.path)
return (
<Dynamic
@@ -116,7 +110,12 @@ const FileTreeNodeV2 = (
function GuideLines(props: { level: number }) {
return (
<For each={Array.from({ length: props.level })}>
{(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />}
{(_, index) => (
<div
class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
style={`left: ${guideLineLeft(index())}px`}
/>
)}
</For>
)
}
@@ -127,18 +126,12 @@ export default function FileTreeV2(props: {
kinds?: ReadonlyMap<string, Kind>
draggable?: boolean
onFileClick?: (file: FileNode) => void
onFileDoubleClick?: (file: FileNode) => void
}) {
const file = useFile()
const live = () => props.allowed === undefined
const draggable = () => props.draggable ?? true
const active = () => normalizeFileTreeV2Path(props.active ?? "")
const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? [])))
const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live()
const rows = createMemo(() => {
if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded)
return flattenFileTreeV2(model()!, expanded)
})
const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? []))
const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true))
const [root, setRoot] = createSignal<HTMLDivElement>()
const [focused, setFocused] = createSignal<string>()
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
@@ -162,49 +155,16 @@ export default function FileTreeV2(props: {
return [...indexes, index].sort((a, b) => a - b)
},
})
createEffect(() => {
if (!live()) return
void file.tree.list("")
})
// Only scroll when the active path changes (or first appears in the tree).
// Do not re-scroll when expand/collapse reshuffles `rows()`.
let scrolledActive: string | undefined
createEffect(() => {
const path = active()
if (!path) {
scrolledActive = undefined
return
}
if (!path) return
const index = rows().findIndex((row) => row.node.path === path)
if (index < 0) return
if (scrolledActive === path) return
scrolledActive = path
queueMicrotask(() => {
const next = rows().findIndex((row) => row.node.path === path)
if (next < 0) return
if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return
virtualizer.scrollToIndex(next, { align: "auto" })
if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
virtualizer.scrollToIndex(index, { align: "auto" })
})
})
const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => {
action?.({
...node,
path: node.originalPath,
absolute: node.originalPath,
})
}
const toggleDirectory = (path: string, originalPath: string) => {
if (expanded(path)) {
file.tree.collapse(originalPath)
return
}
file.tree.expand(originalPath, live() ? undefined : { list: 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)),
@@ -215,7 +175,7 @@ export default function FileTreeV2(props: {
<div
ref={setRoot}
data-component="file-tree-v2"
data-total-rows={live() ? rows().length : model()!.total}
data-total-rows={model().total}
class="group/file-tree-v2"
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
>
@@ -249,8 +209,13 @@ export default function FileTreeV2(props: {
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() => selectFile(row().node, props.onFileClick)}
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
onClick={() =>
props.onFileClick?.({
...row().node,
path: row().node.originalPath,
absolute: row().node.originalPath,
})
}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
@@ -274,13 +239,17 @@ export default function FileTreeV2(props: {
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
aria-expanded={expanded(row().node.path)}
onClick={() => toggleDirectory(row().node.path, row().node.originalPath)}
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)
}
>
<GuideLines level={row().level} />
<div
data-slot="file-tree-v2-chevron"
data-expanded={expanded(row().node.path) ? "" : undefined}
data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
class="size-4 flex items-center justify-center"
>
<Icon name="chevron-down" />
+2 -17
View File
@@ -19,7 +19,6 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/f
import {
ContentPart,
DEFAULT_PROMPT,
isCommentItem,
isPromptEqual,
Prompt,
usePrompt,
@@ -1627,7 +1626,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)}
/>
<PromptContextItems
items={contextItems().filter((item) => !isCommentItem(item))}
items={contextItems()}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
@@ -1637,7 +1636,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
newLayoutDesigns={props.controls.newLayoutDesigns}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<PromptImageAttachments
@@ -1647,17 +1645,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
newLayoutDesigns={props.controls.newLayoutDesigns}
comments={contextItems().filter(isCommentItem)}
commentActive={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
}}
onOpenComment={openComment}
onRemoveComment={(item) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
/>
<div
class="relative min-h-[52px]"
@@ -1865,7 +1852,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
newLayoutDesigns={props.controls.newLayoutDesigns}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<PromptImageAttachments
@@ -1875,7 +1861,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
newLayoutDesigns={props.controls.newLayoutDesigns}
/>
<div
class="relative"
@@ -2356,7 +2341,7 @@ function ModelControlContent(props: { state: ComposerModelControlState; v2?: boo
/>
)}
</Show>
<span class="truncate leading-4">{props.state.modelName}</span>
<span class="truncate">{props.state.modelName}</span>
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
@@ -1,8 +1,6 @@
import { Component, For, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
import type { ContextItem } from "@/context/prompt"
@@ -14,7 +12,6 @@ type ContextItemsProps = {
active: (item: PromptContextItem) => boolean
openComment: (item: PromptContextItem) => void
remove: (item: PromptContextItem) => void
newLayoutDesigns: boolean
t: (key: string) => string
}
@@ -30,17 +27,10 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
const selected = props.active(item)
return (
<Dynamic
component={props.newLayoutDesigns ? TooltipV2 : Tooltip}
<TooltipV2
value={
<span class="flex max-w-[300px]">
<span
classList={{
"truncate-start [unicode-bidi:plaintext] min-w-0": true,
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-invert-base": !props.newLayoutDesigns,
}}
>
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
{directory}
</span>
<span class="shrink-0">{filename}</span>
@@ -88,7 +78,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
</Show>
</div>
</Dynamic>
</TooltipV2>
)
}}
</For>
@@ -1,43 +0,0 @@
@keyframes prompt-attachments-fade-left {
from {
visibility: hidden;
}
to {
visibility: visible;
}
}
@keyframes prompt-attachments-fade-right {
from {
visibility: visible;
}
to {
visibility: hidden;
}
}
[data-slot="prompt-attachments"] {
timeline-scope: --prompt-attachments-scroll;
[data-slot^="prompt-attachments-fade-"] {
visibility: hidden;
}
}
@supports (animation-timeline: --prompt-attachments-scroll) and (timeline-scope: --prompt-attachments-scroll) {
[data-slot="prompt-attachments-scroll"] {
scroll-timeline: --prompt-attachments-scroll x;
}
[data-slot="prompt-attachments-fade-left"] {
animation: prompt-attachments-fade-left linear both;
animation-timeline: --prompt-attachments-scroll;
animation-range: 0 0.1px;
}
[data-slot="prompt-attachments-fade-right"] {
animation: prompt-attachments-fade-right linear both;
animation-timeline: --prompt-attachments-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
}
}
@@ -1,167 +1,60 @@
import { Component, For, Show } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { AttachmentCardV2 } from "@opencode-ai/session-ui/v2/attachment-card-v2"
import { CommentCardV2 } from "@opencode-ai/session-ui/v2/comment-card-v2"
import { typeLabel } from "@opencode-ai/session-ui/message-file"
import type { ContextItem, ImageAttachmentPart } from "@/context/prompt"
import "./image-attachments.css"
type PromptCommentItem = ContextItem & { key: string }
import type { ImageAttachmentPart } from "@/context/prompt"
type PromptImageAttachmentsProps = {
attachments: ImageAttachmentPart[]
onOpen: (attachment: ImageAttachmentPart) => void
onRemove: (id: string) => void
removeLabel: string
newLayoutDesigns: boolean
comments?: PromptCommentItem[]
commentActive?: (item: PromptCommentItem) => boolean
onOpenComment?: (item: PromptCommentItem) => void
onRemoveComment?: (item: PromptCommentItem) => void
}
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
const imageClass =
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
const imageClassV2 = "w-[58px] h-[46px] rounded-[6px] object-cover"
// inset box-shadows do not paint over <img> content, so the hairline is a separate overlay
const imageHairlineClassV2 =
"absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none"
const removeClass =
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
const removeClassV2 =
"absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
return (
<Show when={props.attachments.length > 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}>
<div data-slot="prompt-attachments" classList={{ relative: props.newLayoutDesigns }}>
<div
data-slot="prompt-attachments-scroll"
classList={{
"flex gap-2": true,
"flex-nowrap overflow-x-auto no-scrollbar px-2 pt-2 pb-1": props.newLayoutDesigns,
"flex-wrap px-3 pt-3": !props.newLayoutDesigns,
}}
>
<Show when={props.newLayoutDesigns}>
<For each={props.comments ?? []}>
{(item) => (
<div class="relative group shrink-0">
<TooltipV2
value={item.comment}
placement="top"
openDelay={800}
contentClass="max-w-[300px] break-words"
>
<CommentCardV2
comment={item.comment ?? ""}
path={item.path}
selection={item.selection}
active={props.commentActive?.(item)}
onClick={() => props.onOpenComment?.(item)}
/>
</TooltipV2>
<button
type="button"
onClick={() => props.onRemoveComment?.(item)}
class={removeClassV2}
aria-label={props.removeLabel}
>
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
</button>
</div>
)}
</For>
</Show>
<For each={props.attachments}>
{(attachment) => {
const image = attachment.mime.startsWith("image/")
const media = () => (
<Show when={props.attachments.length > 0}>
<div class="flex flex-wrap gap-2 px-3 pt-3">
<For each={props.attachments}>
{(attachment) => (
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
<div class="relative group">
<Show
when={image}
when={attachment.mime.startsWith("image/")}
fallback={
<Show
when={props.newLayoutDesigns}
fallback={
<div class={fallbackClass}>
<Icon name="folder" class="size-6 text-text-weak" />
</div>
}
>
<AttachmentCardV2 title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime)}
</AttachmentCardV2>
</Show>
<div class={fallbackClass}>
<Icon name="folder" class="size-6 text-text-weak" />
</div>
}
>
<img
src={attachment.dataUrl}
alt={attachment.filename}
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
class={imageClass}
onClick={() => props.onOpen(attachment)}
/>
</Show>
)
const name = () => (
<div class={nameClass}>
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
</div>
)
const remove = () => (
<button
type="button"
onClick={() => props.onRemove(attachment.id)}
class={props.newLayoutDesigns ? removeClassV2 : removeClass}
class={removeClass}
aria-label={props.removeLabel}
>
<Show when={props.newLayoutDesigns} fallback={<Icon name="close" class="size-3 text-text-weak" />}>
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
</Show>
<Icon name="close" class="size-3 text-text-weak" />
</button>
)
// v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip
return (
<Show
when={props.newLayoutDesigns}
fallback={
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
<div class="relative group">
{media()}
{name()}
{remove()}
</div>
</Tooltip>
}
>
<div class="relative group shrink-0">
<TooltipV2 value={attachment.filename} placement="top" contentClass="break-all">
{media()}
<Show when={image}>
<div class={imageHairlineClassV2} />
</Show>
</TooltipV2>
{remove()}
</div>
</Show>
)
}}
</For>
</div>
<Show when={props.newLayoutDesigns}>
<div
data-slot="prompt-attachments-fade-left"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
/>
<div
data-slot="prompt-attachments-fade-right"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
/>
</Show>
<div class={nameClass}>
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
</div>
</div>
</Tooltip>
)}
</For>
</div>
</Show>
)
@@ -1,6 +1,5 @@
import { For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path"
@@ -97,17 +96,10 @@ export function PromptWorkspaceSelector(props: {
{(branch) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
placement="top"
value={branch()}
class="min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{branch()}</span>
</div>
</TooltipV2>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{branch()}</span>
</div>
</>
)}
</Show>
@@ -1,7 +1,6 @@
export { SessionHeader } from "./session-header"
export { SessionContextTab } from "./session-context-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTabV2 } from "./session-sortable-tab-v2"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view"
@@ -1,98 +0,0 @@
import { For, Show } from "solid-js"
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language"
import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app"
export function OpenInAppV2(props: { directory: () => string }) {
const language = useLanguage()
const state = useOpenInApp(props)
return (
<Show when={props.directory() && state.canOpen()}>
<SplitButtonV2 class="session-review-v2-open-in-app" onPointerDown={(event) => event.stopPropagation()}>
<TooltipV2
placement="bottom"
value={language.t("session.header.open.ariaLabel", { app: state.current().label })}
class="flex items-center"
>
<SplitButtonV2Action
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
if (state.opening()) return
state.openDir(state.current().id)
}}
disabled={state.opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
>
<Show when={state.opening()} fallback={<AppIcon id={state.current().icon} class="size-[18px]" />}>
<Spinner class="size-3.5" />
</Show>
</SplitButtonV2Action>
</TooltipV2>
<MenuV2
gutter={4}
modal={false}
placement="bottom-end"
open={state.menu.open}
onOpenChange={(open) => state.setMenu("open", open)}
>
<MenuV2.Trigger
as={SplitButtonV2MenuTrigger}
disabled={state.opening()}
aria-label={language.t("session.header.open.menu")}
onPointerDown={(event) => event.stopPropagation()}
>
<IconV2 name="chevron-down" size="small" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="open-in-app-v2-menu">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.header.openIn")}</MenuV2.GroupLabel>
<MenuV2.RadioGroup
value={state.current().id}
onChange={(value) => {
state.selectApp(value as OpenApp)
}}
>
<For each={state.options()}>
{(option) => (
<MenuV2.RadioItem
value={option.id}
disabled={state.opening()}
onSelect={() => {
state.selectApp(option.id)
state.setMenu("open", false)
state.openDir(option.id)
}}
>
<AppIcon id={option.icon} />
{option.label}
</MenuV2.RadioItem>
)}
</For>
</MenuV2.RadioGroup>
</MenuV2.Group>
<MenuV2.Separator />
<MenuV2.Item
onSelect={() => {
state.setMenu("open", false)
state.copyPath()
}}
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</SplitButtonV2>
</Show>
)
}
@@ -1,229 +0,0 @@
import { createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@/utils/toast"
export const OPEN_APPS = [
"vscode",
"cursor",
"zed",
"textmate",
"antigravity",
"finder",
"terminal",
"iterm2",
"ghostty",
"warp",
"xcode",
"android-studio",
"powershell",
"sublime-text",
] as const
export type OpenApp = (typeof OPEN_APPS)[number]
export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
export const MAC_OPEN_APPS = [
{
id: "vscode",
label: "session.header.open.app.vscode",
icon: "vscode",
openWith: "Visual Studio Code",
},
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
{
id: "antigravity",
label: "session.header.open.app.antigravity",
icon: "antigravity",
openWith: "Antigravity",
},
{ id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
{ id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
{
id: "android-studio",
label: "session.header.open.app.androidStudio",
icon: "android-studio",
openWith: "Android Studio",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
export const WINDOWS_OPEN_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "powershell",
label: "session.header.open.app.powershell",
icon: "powershell",
openWith: "powershell",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
export const LINUX_OPEN_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
export function detectOpenAppOS(platform: ReturnType<typeof usePlatform>): OpenAppOS {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
const value = navigator.platform || navigator.userAgent
if (/Mac/i.test(value)) return "macos"
if (/Win/i.test(value)) return "windows"
if (/Linux/i.test(value)) return "linux"
return "unknown"
}
export function openAppFileManager(os: OpenAppOS) {
if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
return { label: "session.header.open.fileManager", icon: "finder" as const }
}
export function openAppsForOS(os: OpenAppOS) {
if (os === "macos") return MAC_OPEN_APPS
if (os === "windows") return WINDOWS_OPEN_APPS
return LINUX_OPEN_APPS
}
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
export function useOpenInApp(input: { directory: () => string }) {
const platform = usePlatform()
const server = useServer()
const language = useLanguage()
const os = createMemo(() => detectOpenAppOS(platform))
const apps = createMemo(() => openAppsForOS(os()))
const fileManager = createMemo(() => openAppFileManager(os()))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
})
createEffect(() => {
if (platform.platform !== "desktop") return
if (!platform.checkAppExists) return
const list = apps()
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
void Promise.all(
list.map((app) =>
Promise.resolve(platform.checkAppExists?.(app.openWith))
.then((value) => Boolean(value))
.catch(() => false)
.then((ok) => [app.id, ok] as const),
),
).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
})
})
const options = createMemo(() => {
return [
{ id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
...apps()
.filter((app) => exists[app.id])
.map((app) => ({ ...app, label: language.t(app.label) })),
] as const
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp | "finder" }))
const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({
app: undefined as OpenApp | undefined,
})
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(
() =>
options().find((o) => o.id === prefs.app) ??
options()[0] ??
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
)
const opening = createMemo(() => openRequest.app !== undefined)
const selectApp = (app: OpenApp | "finder") => {
if (!options().some((item) => item.id === app)) return
setPrefs("app", app)
}
const openDir = (app: OpenApp | "finder") => {
if (opening() || !canOpen() || !platform.openPath) return
const directory = input.directory()
if (!directory) return
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
setOpenRequest("app", app)
platform
.openPath(directory, openWith)
.catch((err: unknown) => showRequestError(language, err))
.finally(() => {
setOpenRequest("app", undefined)
})
}
const copyPath = () => {
const directory = input.directory()
if (!directory) return
navigator.clipboard
.writeText(directory)
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: directory,
})
})
.catch((err: unknown) => showRequestError(language, err))
}
return {
canOpen,
opening,
current,
options,
menu,
setMenu,
openDir,
selectApp,
copyPath,
}
}
@@ -1,66 +0,0 @@
import { createMemo, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useSortable } from "@dnd-kit/solid/sortable"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { FileVisual } from "./session-sortable-tab"
export function SortableTabV2(props: {
tab: string
index: () => number
temporary?: boolean
onTabClose: (tab: string) => void
onTabDoubleClick?: (tab: string) => void
}): JSX.Element {
const file = useFile()
const language = useLanguage()
const command = useCommand()
const sortable = useSortable({
get id() {
return props.tab
},
get index() {
return props.index()
},
})
const path = createMemo(() => file.pathFromTab(props.tab))
const content = createMemo(() => {
const value = path()
if (!value) return
return <FileVisual path={value} temporary={props.temporary} />
})
return (
<div ref={sortable.ref} class="h-full flex items-center">
<div class="relative">
<Tabs.Trigger
value={props.tab}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
>
<Show when={content()}>{(value) => value()}</Show>
</Tabs.Trigger>
</div>
</div>
)
}
+4 -12
View File
@@ -47,20 +47,12 @@ export function getAvatarColors(key?: string) {
}
export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
if (key === "orange") return "orange"
if (key === "pink") return "pink"
if (key === "cyan") return "cyan"
if (key === "purple") return "purple"
if (key === "mint") return "cyan"
if (key === "lime") return "green"
if (
key === "orange" ||
key === "yellow" ||
key === "cyan" ||
key === "green" ||
key === "red" ||
key === "pink" ||
key === "blue" ||
key === "purple" ||
key === "gray"
)
return key
return "gray"
}
-3
View File
@@ -37,9 +37,6 @@ type PlatformBase = {
/** Open a local path in a local app (desktop only) */
openPath?(path: string, app?: string): Promise<void>
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
revealPath?(path: string): Promise<boolean>
/** Restart the app */
restart(): Promise<void>
+1 -1
View File
@@ -145,7 +145,7 @@ function contextItemKey(item: ContextItem) {
return `${key}:c=${digest.slice(0, 8)}`
}
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
-1
View File
@@ -24,7 +24,6 @@ export {
createPromptSession,
createPromptState,
DEFAULT_PROMPT,
isCommentItem,
isPromptEqual,
} from "./prompt-state"
export type {
+1 -1
View File
@@ -638,7 +638,7 @@ export const dict = {
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab",
"session.error.serverConnection": "Can't connect to this server",
"session.review.filesChanged": "Files Changed {{count}}",
"session.review.filesChanged": "{{count}} Files Changed",
"session.review.change.one": "Change",
"session.review.change.other": "Changes",
"session.review.loadingChanges": "Loading changes...",
@@ -2,30 +2,11 @@ import { describe, expect, test } from "bun:test"
import { shouldOpenSessionInBackground } from "./home-session-open"
describe("shouldOpenSessionInBackground", () => {
test("opens middle clicks in the background", () => {
expect(
shouldOpenSessionInBackground({ button: 1, mac: true, meta: false, ctrl: false, shift: false, alt: false }),
).toBe(true)
expect(
shouldOpenSessionInBackground({ button: 2, mac: true, meta: false, ctrl: false, shift: false, alt: false }),
).toBe(false)
})
test("requires only the platform primary modifier", () => {
expect(
shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: false, alt: false }),
).toBe(true)
expect(
shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: false }),
).toBe(true)
expect(
shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: true, alt: false }),
).toBe(false)
expect(
shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: true }),
).toBe(false)
expect(
shouldOpenSessionInBackground({ button: 0, mac: false, meta: true, ctrl: false, shift: false, alt: false }),
).toBe(false)
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true)
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true)
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false)
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false)
expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false)
})
})
@@ -1,13 +1,10 @@
export function shouldOpenSessionInBackground(input: {
button: number
mac: boolean
meta: boolean
ctrl: boolean
shift: boolean
alt: boolean
}) {
if (input.button === 1) return true
if (input.button !== 0) return false
if (input.shift || input.alt) return false
if (input.mac) return input.meta && !input.ctrl
return input.ctrl && !input.meta
+4 -21
View File
@@ -240,11 +240,10 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
}
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
// tab in the background without navigating, matching browser conventions.
// Cmd+click on macOS (Ctrl+click elsewhere) opens a session tab in the
// background without navigating, matching browser conventions.
function isBackgroundOpen(event: MouseEvent) {
return shouldOpenSessionInBackground({
button: event.button,
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
meta: event.metaKey,
ctrl: event.ctrlKey,
@@ -466,8 +465,8 @@ export function NewHome() {
}
function editProject(conn: ServerConnection.Any, project: LocalProject) {
void import("@/components/dialog-edit-project-v2").then((x) => {
void dialog.show(() => <x.DialogEditProjectV2 server={conn} project={project} />)
void import("@/components/dialog-edit-project").then((x) => {
dialog.show(() => <x.DialogEditProject server={conn} project={project} />)
})
}
@@ -1387,15 +1386,7 @@ function HomeSessionSearchResultRow(props: {
group: !!showProjectName(),
}}
onMouseEnter={() => props.onHighlight()}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.onSelect(props.record.session, { background: true })
}}
>
<HomeSessionLeading
project={props.record.project}
@@ -1455,15 +1446,7 @@ function HomeSessionRow(props: {
type="button"
data-component="home-session-row"
class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.openSession(props.record.session, { background: true })
}}
>
<HomeSessionLeading
project={props.record.project}
+2 -34
View File
@@ -1,5 +1,4 @@
import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { getFilename } from "@opencode-ai/core/util/path"
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
import {
@@ -80,7 +79,6 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds"
@@ -1906,30 +1904,7 @@ export default function Page() {
.map((item) => ({ id: item.id, text: line(item.id) }))
})
// attachment bytes are embedded as a data URL, so downloading always works;
// revealing requires the on-disk path captured by the client that attached the file
const openAttachment = (file: FilePart) => {
const download = () => {
const anchor = document.createElement("a")
anchor.href = file.url
anchor.download = getFilename(file.filename) || "attachment"
anchor.click()
}
const path = file.filename ?? ""
const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
if (platform.revealPath && absolute) {
void platform.revealPath(path).then(
(revealed) => {
if (!revealed) download()
},
() => download(),
)
return
}
download()
}
const actions = { revert, openAttachment }
const actions = { revert }
createEffect(() => {
const sessionID = params.id
@@ -2301,13 +2276,6 @@ export default function Page() {
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
reviewCount={reviewCount}
reviewPanel={reviewPanelV2}
reviewSidebarToggle={(disabled) => (
<SessionReviewV2SidebarToggle
opened={reviewV2State.sidebarOpened()}
disabled={disabled}
onToggle={reviewV2State.toggleSidebar}
/>
)}
fileBrowserState={reviewV2State}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
+1 -329
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -6,12 +6,9 @@ import type { FileSearchHandle } from "@opencode-ai/session-ui/file"
import { useFileComponent } from "@opencode-ai/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations"
import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2"
import { sampledChecksum } from "@opencode-ai/core/util/encode"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { showToast } from "@/utils/toast"
@@ -19,7 +16,6 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange
import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { useSettings } from "@/context/settings"
import { getSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
@@ -57,30 +53,6 @@ function FileCommentMenu(props: {
)
}
function FileCommentMenuV2(props: {
moreLabel: string
editLabel: string
deleteLabel: string
onEdit: VoidFunction
onDelete: VoidFunction
}) {
return (
<div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<MenuV2 gutter={4}>
<MenuV2.Trigger as="button" type="button" data-slot="line-comment-v2-overflow" aria-label={props.moreLabel}>
<LineCommentV2OverflowIcon />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={props.onEdit}>{props.editLabel}</MenuV2.Item>
<MenuV2.Item onSelect={props.onDelete}>{props.deleteLabel}</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</div>
)
}
type ScrollPos = { x: number; y: number }
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
@@ -208,15 +180,6 @@ export function FileTabContent(props: { tab: string }) {
}
export function SessionFileView(props: { tab: string }) {
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={<SessionFileViewV1 tab={props.tab} />}>
<SessionFileViewV2 tab={props.tab} />
</Show>
)
}
function SessionFileViewV1(props: { tab: string }) {
const file = useFile()
const comments = useComments()
const language = useLanguage()
@@ -500,294 +463,3 @@ function SessionFileViewV1(props: { tab: string }) {
return content()
}
function SessionFileViewV2(props: { tab: string }) {
const file = useFile()
const comments = useComments()
const language = useLanguage()
const prompt = usePrompt()
const fileComponent = useFileComponent()
const { sessionKey, tabs, view } = useSessionLayout()
const activeFileTab = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}).activeFileTab
let find: FileSearchHandle | null = null
const search = {
register: (handle: FileSearchHandle | null) => {
find = handle
},
}
const path = createMemo(() => file.pathFromTab(props.tab))
const state = createMemo(() => {
const p = path()
if (!p) return
return file.get(p)
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents()))
const selectedLines = createMemo<SelectedLineRange | null>(() => {
const p = path()
if (!p) return null
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
})
const scrollSync = createScrollSync({
tab: () => props.tab,
view,
})
const selectionPreview = (source: string, selection: FileSelection) => {
return previewSelectedLines(source, {
start: selection.startLine,
end: selection.endLine,
})
}
const buildPreview = (filePath: string, selection: FileSelection) => {
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => {
const selection = selectionFromLines(input.selection)
const preview = input.preview ?? buildPreview(input.file, selection)
const saved = comments.add({
file: input.file,
selection: input.selection,
comment: input.comment,
})
prompt.context.add({
type: "file",
path: input.file,
selection,
comment: input.comment,
commentID: saved.id,
commentOrigin: input.origin,
preview,
})
}
const updateCommentInContext = (input: {
id: string
file: string
selection: SelectedLineRange
comment: string
}) => {
comments.update(input.file, input.id, input.comment)
const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
prompt.context.updateComment(input.file, input.id, {
comment: input.comment,
...(preview ? { preview } : {}),
})
}
const removeCommentFromContext = (input: { id: string; file: string }) => {
comments.remove(input.file, input.id)
prompt.context.removeComment(input.file, input.id)
}
const fileComments = createMemo(() => {
const p = path()
if (!p) return []
return comments.list(p)
})
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
const [note, setNote] = createStore({
openedComment: null as string | null,
commenting: null as SelectedLineRange | null,
selected: null as SelectedLineRange | null,
})
const syncSelected = (range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null)
}
const activeSelection = () => note.selected ?? selectedLines()
const commentsUi = createLineCommentControllerV2({
comments: fileComments,
label: language.t("ui.lineComment.submit"),
draftKey: () => path() ?? props.tab,
mention: {
items: file.searchFilesAndDirectories,
},
getSide: (range) => range.endSide ?? range.side ?? "additions",
state: {
opened: () => note.openedComment,
setOpened: (id) => setNote("openedComment", id),
selected: () => note.selected,
setSelected: (range) => setNote("selected", range),
commenting: () => note.commenting,
setCommenting: (range) => setNote("commenting", range),
syncSelected,
hoverSelected: syncSelected,
},
onSubmit: ({ comment, selection }) => {
const p = path()
if (!p) return
addCommentToContext({ file: p, selection, comment, origin: "file" })
},
onUpdate: ({ id, comment, selection }) => {
const p = path()
if (!p) return
updateCommentInContext({ id, file: p, selection, comment })
},
onDelete: (comment) => {
const p = path()
if (!p) return
removeCommentFromContext({ id: comment.id, file: p })
},
editSubmitLabel: language.t("common.save"),
renderCommentActions: (_, controls) => (
<FileCommentMenuV2
moreLabel={language.t("common.moreOptions")}
editLabel={language.t("common.edit")}
deleteLabel={language.t("common.delete")}
onEdit={controls.edit}
onDelete={controls.remove}
/>
),
})
createEffect(() => {
if (typeof window === "undefined") return
const onKeyDown = (event: KeyboardEvent) => {
if (activeFileTab() !== props.tab) return
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
if (event.key.toLowerCase() !== "f") return
event.preventDefault()
event.stopPropagation()
find?.focus()
}
makeEventListener(window, "keydown", onKeyDown, { capture: true })
})
createEffect(
on(
path,
() => {
commentsUi.note.reset()
},
{ defer: true },
),
)
createEffect(() => {
const focus = comments.focus()
const p = path()
if (!focus || !p) return
if (focus.file !== p) return
if (activeFileTab() !== props.tab) return
const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return
commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true })
requestAnimationFrame(() => comments.clearFocus())
})
let prev = {
loaded: false,
ready: false,
active: false,
}
createEffect(() => {
const loaded = !!state()?.loaded
const ready = file.ready()
const active = activeFileTab() === props.tab
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
prev = { loaded, ready, active }
if (!restore) return
scrollSync.queueRestore()
})
const renderFile = (source: string) => (
<div class="relative overflow-hidden pb-40">
<Dynamic
component={fileComponent}
mode="text"
file={{
name: path() ?? "",
contents: source,
cacheKey: cacheKey(),
}}
enableLineSelection
enableGutterUtility
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
scrollSync.queueRestore()
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderGutterUtility={commentsUi.renderGutterUtility}
onLineSelected={(range: SelectedLineRange | null) => {
commentsUi.onLineSelected(range)
}}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) {
commentsUi.note.select(null)
commentsUi.note.cancelDraft()
return
}
commentsUi.onLineSelectionEnd(range)
}}
onLineNumberSelectionEnd={(range: SelectedLineRange | null) => {
commentsUi.onLineNumberSelectionEnd(range)
}}
search={search}
class="select-text"
media={{
mode: "auto",
path: path(),
current: state()?.content,
onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
})
},
}}
/>
</div>
)
const content = () => (
<div class="mt-3 relative h-full min-h-0">
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</ScrollView>
</div>
)
return content()
}
@@ -1,46 +1,28 @@
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import {
DragDropProvider,
DragDropSensors,
DragOverlay,
SortableProvider,
closestCenter,
type DragEvent,
} from "@thisbeyond/solid-dnd"
import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import FileTree from "@/components/file-tree"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
import { SessionContextUsage } from "@/components/session-context-usage"
const reviewTabID = "session-side-panel-review-tab"
const reviewTabPanelID = "session-side-panel-review-tabpanel"
const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session"
import { OpenInAppV2 } from "@/components/session/open-in-app-v2"
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
@@ -71,7 +53,6 @@ export function SessionSidePanel(props: {
reviewHasFocusableContent: () => boolean
reviewCount: () => number
reviewPanel: () => JSX.Element
reviewSidebarToggle?: (disabled: boolean) => JSX.Element
fileBrowserState?: SessionFileBrowserState
activeDiff?: string
focusReviewDiff: (path: string) => void
@@ -85,9 +66,7 @@ export function SessionSidePanel(props: {
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
const sdk = useSDK()
const { sessionKey, tabs, view, params } = useSessionLayout()
const projectDirectory = createMemo(() => sdk().directory)
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = settings.visibility.fileTree
@@ -119,9 +98,11 @@ export function SessionSidePanel(props: {
return "mix" as const
}
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
const file = normalizeFileTreeV2Path(diff.file)
const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
@@ -178,7 +159,6 @@ export function SessionSidePanel(props: {
fileBrowser: () => !!props.fileBrowserState,
})
const contextOpen = tabState.contextOpen
const openFileOpen = tabState.openFileOpen
const panelTabs = tabState.panelTabs
const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab
@@ -196,8 +176,10 @@ export function SessionSidePanel(props: {
layout.fileTree.setTab("all")
}
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
})
let fileFilter: HTMLInputElement | undefined
let tabList: HTMLDivElement | undefined
const temporaryTab = tabs().preview
const previewTab = (value: string) => {
const next = normalizeTab(value)
@@ -220,26 +202,10 @@ export function SessionSidePanel(props: {
}
const browserTab = createMemo(() => {
if (!props.fileBrowserState) return undefined
const active = activeTab()
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
if (active && file.pathFromTab(active)) return active
if (activeTab() === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
return activeFileTab()
})
// Keep the file-browser shell mounted while any file tab exists. Kobalte briefly
// selects Review while the tab For replaces a preview trigger, which would
// otherwise dispose the sidebar and reset scroll.
const fileBrowserMounted = createMemo(() => {
if (!props.fileBrowserState) return false
return openedTabs().length > 0 || openFileOpen() || !!browserTab()
})
const fileBrowserVisible = createMemo(() => {
const active = activeTab()
return active !== "review" && active !== "context" && active !== "empty"
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
})
const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
@@ -325,283 +291,72 @@ export function SessionSidePanel(props: {
"bg-background-base": !settings.general.newLayoutDesigns(),
}}
>
<Show
when={props.fileBrowserState}
fallback={
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={activateTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={activateTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={panelTabs()}>
{(tab) => (
<Show
when={tab === SESSION_OPEN_FILE_TAB}
fallback={
<SortableTab
tab={tab}
temporary={temporaryTab() === tab}
onTabClose={tabs().close}
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
/>
}
>
<Tabs.Trigger
value={SESSION_OPEN_FILE_TAB}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
>
<div class="flex items-center gap-1.5 italic">
<Icon name="open-file" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Tabs.Trigger>
</Show>
)}
</For>
</SortableProvider>
<div
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="plus-small"
icon="close-small"
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
}}
aria-label={language.t("command.file.open")}
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
</div>
</Tabs.List>
</div>
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
{props.reviewPanel()}
</div>
</Show>
<Show when={activeTab() === "empty"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Content>
</Tabs.Trigger>
</Show>
<Show when={activeTab() === "context"}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Tabs.Content>
</Show>
<Show when={activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = file.pathFromTab(tab)
return (
<div data-component="tabs-drag-preview">
<Show when={path}>
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
}
>
<DndKitProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
event.target instanceof Element &&
(!!event.target.closest('[data-slot="tabs-trigger-close-button"]') ||
!!event.target.closest(".session-review-v2-open-in-app-slot")),
}),
]}
modifiers={[
RestrictToHorizontalAxis,
RestrictToElement.configure({ element: () => tabList ?? null }),
]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== Accessibility),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return
tabs().move(source.id.toString(), source.index)
}}
>
<Tabs value={activeTab()} onChange={activateTab}>
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
<Tabs.List
ref={(el: HTMLDivElement) => {
tabList = el
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={props.reviewSidebarToggle}>
{(toggle) => (
<div class="h-full shrink-0 flex items-center justify-center">
{toggle()(activeTab() === SESSION_OPEN_FILE_TAB)}
</div>
)}
</Show>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
>
{props.hasReview()
? language.t("session.review.filesChanged", { count: props.reviewCount() })
: language.t("session.tab.review")}
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={panelTabs()}>
{(tab) => (
<Show
when={tab === SESSION_OPEN_FILE_TAB}
fallback={
<SortableTabV2
<SortableTab
tab={tab}
index={() => tabs().all().indexOf(tab)}
temporary={temporaryTab() === tab}
onTabClose={tabs().close}
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
@@ -637,104 +392,106 @@ export function SessionSidePanel(props: {
</Show>
)}
</For>
<div
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
</SortableProvider>
<div
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<TooltipV2
value={
<>
{language.t("command.file.open")}
<Show when={openFileKeybind().length > 0}>
<KeybindV2 keys={openFileKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
class="flex items-center"
>
<IconButtonV2
icon={<Icon name="plus-small" />}
variant="ghost-muted"
size="large"
onClick={() => openFileBrowser()}
aria-label={language.t("command.file.open")}
/>
</TooltipV2>
</div>
</Tabs.List>
<div
class="session-review-v2-open-in-app-slot shrink-0 flex items-center pr-3"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<OpenInAppV2 directory={projectDirectory} />
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() => {
if (props.fileBrowserState) {
openFileBrowser()
return
}
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
}}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</div>
</Tabs.List>
</div>
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
>
{props.reviewPanel()}
</div>
</Show>
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
>
{props.reviewPanel()}
</div>
</Show>
<Show when={activeTab() === "empty"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
<Show when={activeTab() === "empty"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</Tabs.Content>
</Show>
<Show when={activeTab() === "context"}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Tabs.Content>
</Show>
<Show when={fileBrowserMounted()}>
<div
id={fileBrowserTabPanelID}
role="tabpanel"
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden"
classList={{ hidden: !fileBrowserVisible() }}
inert={!fileBrowserVisible() || undefined}
>
<SessionFileBrowserTab
tab={browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
placeholder={
(browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
}
active={file.pathFromTab(browserTab() ?? activeFileTab() ?? "")}
kinds={kinds()}
state={props.fileBrowserState!}
onSelect={(path) => previewTab(file.tab(path))}
onSelectPermanent={(path) => openTab(file.tab(path))}
filterRef={(element) => (fileFilter = element)}
/>
</div>
</Show>
</Tabs>
</DndKitProvider>
</Show>
</Tabs.Content>
</Show>
<Show when={activeTab() === "context"}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Tabs.Content>
</Show>
<Show when={browserTab()}>
<SessionFileBrowserTab
tab={browserTab()!}
placeholder={browserTab() === SESSION_OPEN_FILE_TAB}
active={file.pathFromTab(browserTab()!)}
kinds={browserKinds()}
state={props.fileBrowserState!}
onSelect={(path) => previewTab(file.tab(path))}
onSelectPermanent={(path) => openTab(file.tab(path))}
filterRef={(element) => (fileFilter = element)}
/>
</Show>
<Show when={!props.fileBrowserState && activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = file.pathFromTab(tab)
return (
<div data-component="tabs-drag-preview">
<Show when={path}>
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</div>
</div>
</Show>
@@ -762,19 +519,10 @@ export function SessionSidePanel(props: {
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
<Show
when={settings.general.newLayoutDesigns()}
fallback={
<>
{props.reviewCount()}{" "}
{language.t(
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
)}
</>
}
>
{language.t("session.review.filesChanged", { count: props.reviewCount() })}
</Show>
{props.reviewCount()}{" "}
{language.t(
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
)}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
@@ -327,7 +327,6 @@ export function MessageTimeline(props: {
parts: getMsgParts,
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
inlineComments: settings.general.newLayoutDesigns,
})
const activeMessageID = projection.activeMessageID
const assistantMessagesByParent = projection.assistantMessagesByParent
@@ -1136,10 +1135,6 @@ export function MessageTimeline(props: {
const m = messageByID().get(userMessageRow().userMessageID)
if (m?.role === "user") return m
})
const messageComments = createMemo(() => {
if (!settings.general.newLayoutDesigns()) return []
return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
})
return (
<TimelineRowFrame row={userMessageRow}>
<Show when={message()}>
@@ -1151,7 +1146,6 @@ export function MessageTimeline(props: {
parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions}
useV2Actions={settings.general.newLayoutDesigns()}
comments={messageComments()}
/>
</div>
</div>
@@ -14,7 +14,6 @@ export function createTimelineProjection(input: {
parts: (messageID: string) => Part[]
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
inlineComments: Accessor<boolean>
}) {
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
const assistantMessagesByParent = createMemo(() => {
@@ -60,7 +59,6 @@ export function createTimelineProjection(input: {
input.showReasoningSummaries(),
input.status().type,
activeMessageID() === userMessage.id,
input.inlineComments(),
),
),
),
@@ -38,8 +38,6 @@ export namespace Timeline {
showReasoning: boolean,
status: SessionStatus["type"],
isActive: boolean,
// v2 renders comments inside the user message attachments row instead of a strip row
inlineComments: boolean,
) {
const rows: TimelineRow.TimelineRow[] = []
@@ -76,7 +74,7 @@ export namespace Timeline {
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
if (comments.length > 0 && !inlineComments)
if (comments.length > 0)
rows.push(
new TimelineRow.CommentStrip({
userMessageID: userMessage.id,
@@ -86,7 +84,7 @@ export namespace Timeline {
rows.push(
new TimelineRow.UserMessage({
userMessageID: userMessage.id,
anchor: inlineComments || comments.length === 0,
anchor: comments.length === 0,
}),
)
@@ -5,6 +5,7 @@ import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
SessionReviewV2,
SessionReviewV2Sidebar,
SessionReviewV2SidebarToggle,
} from "@opencode-ai/session-ui/v2/session-review-v2"
import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2"
import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
@@ -65,8 +66,6 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
)
const searching = createMemo(() => props.state.filter().trim().length > 0)
const kinds = createMemo(() => reviewDiffKinds(diffs()))
// Changes-only trees omit "M" — every row is already a change; A/D stay visible.
const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
const activeDiff = createMemo(() => {
// A focused comment takes over the preview until the preview applies it and
// clears the focus; the owner then persists the file as the active selection.
@@ -114,6 +113,9 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
stats={<DiffChanges changes={diffs()} />}
empty={props.empty}
sidebarOpen={props.state.sidebarOpened()}
sidebarToggle={
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
}
sidebar={
// Always mounted: the sidebar header hosts the changes-mode dropdown,
// which must stay reachable when the current mode has zero diffs.
@@ -125,7 +127,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
diffs={diffs}
filteredFiles={filteredFiles}
searching={searching}
kinds={treeKinds}
kinds={kinds}
activeDiff={activeDiff}
/>
}
@@ -1,9 +1,14 @@
import { createMemo, createSignal, createUniqueId, Show } from "solid-js"
import { createQuery } from "@tanstack/solid-query"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode-ai/session-ui/v2/session-file-panel-v2"
import { SessionReviewV2Sidebar } from "@opencode-ai/session-ui/v2/session-review-v2"
import FileTreeV2, { type Kind } from "@/components/file-tree-v2"
import {
SessionFilePanelV2,
SessionFilePanelV2Empty,
SessionFilePanelV2Title,
} from "@opencode-ai/session-ui/v2/session-file-panel-v2"
import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
import FileTree, { type Kind } from "@/components/file-tree"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
@@ -88,92 +93,102 @@ export function SessionFileBrowserTab(props: {
})
}
// Keep the sidebar outside Kobalte Tabs.Content: a morphing content value
// unmounts the whole panel on every file-tab switch and resets sidebar scroll.
return (
<SessionFilePanelV2
toolbar={false}
sidebar={
<SessionReviewV2Sidebar
open={sidebarOpened()}
title={<span class="truncate">{title()}</span>}
filter={filter()}
onFilterChange={setFilter}
onFilterKeyDown={onFilterKeyDown}
filterAutofocus={props.placeholder}
filterRef={props.filterRef}
filterControls={resultsID}
filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
filterExpanded={query().length > 0 && files().length > 0}
width={props.state.sidebarWidth()}
onWidthChange={props.state.resizeSidebar}
>
<Show
when={query()}
fallback={
<FileTreeV2
active={props.active}
kinds={props.kinds}
onFileClick={(node) => props.onSelect(node.path)}
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
/>
}
<Tabs.Content value={props.tab} class="h-full min-h-0 overflow-hidden">
<SessionFilePanelV2
toolbar
toolbarStart={
<>
<SessionReviewV2SidebarToggle opened={sidebarOpened()} onToggle={props.state.toggleSidebar} />
<Show when={!sidebarOpened()}>
<SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title>
</Show>
</>
}
sidebar={
<SessionReviewV2Sidebar
open={sidebarOpened()}
title={<span class="truncate">{title()}</span>}
filter={filter()}
onFilterChange={setFilter}
onFilterKeyDown={onFilterKeyDown}
filterAutofocus={props.placeholder}
filterRef={props.filterRef}
filterControls={resultsID}
filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
filterExpanded={query().length > 0 && files().length > 0}
width={props.state.sidebarWidth()}
onWidthChange={props.state.resizeSidebar}
>
<Show
when={!loading()}
when={query()}
fallback={
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
<FileTree
path=""
class="pt-1"
active={props.active}
kinds={props.kinds}
onFileClick={(node) => props.onSelect(node.path)}
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
/>
}
>
<Show
when={files().length > 0}
when={!loading()}
fallback={
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
{language.t("palette.empty")}
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<SessionFileListV2
id={resultsID}
role="listbox"
optionID={optionID}
files={files()}
kinds={props.kinds}
active={props.active}
highlighted={highlighted()}
onFileClick={(path) => {
setExplicitHighlight(path)
props.onSelect(path)
}}
onFileDoubleClick={props.onSelectPermanent}
/>
<Show
when={files().length > 0}
fallback={
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
{language.t("palette.empty")}
</div>
}
>
<SessionFileListV2
id={resultsID}
role="listbox"
optionID={optionID}
files={files()}
kinds={props.kinds}
active={props.active}
highlighted={highlighted()}
onFileClick={(path) => {
setExplicitHighlight(path)
props.onSelect(path)
}}
onFileDoubleClick={props.onSelectPermanent}
/>
</Show>
</Show>
</Show>
</Show>
</SessionReviewV2Sidebar>
}
>
<Show
when={!props.placeholder}
fallback={
<SessionFilePanelV2Empty>
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
<Icon name="file-tree" size="large" />
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
</div>
</SessionFilePanelV2Empty>
</SessionReviewV2Sidebar>
}
>
<div class="min-h-0 flex-1">
<Show when={props.tab} keyed>
{(tab) => <SessionFileView tab={tab} />}
</Show>
</div>
</Show>
</SessionFilePanelV2>
<Show
when={!props.placeholder}
fallback={
<SessionFilePanelV2Empty>
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
<Icon name="file-tree" size="large" />
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
</div>
</SessionFilePanelV2Empty>
}
>
<div class="min-h-0 flex-1">
<Show when={props.tab} keyed>
{(tab) => <SessionFileView tab={tab} />}
</Show>
</div>
</Show>
</SessionFilePanelV2>
</Tabs.Content>
)
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.17.19",
"version": "1.17.18",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.17.19",
"version": "1.17.18",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.17.19",
"version": "1.17.18",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.17.19",
"version": "1.17.18",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.17.19",
"version": "1.17.18",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.17.19",
"version": "1.17.18",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.17.19",
"version": "1.17.18",
"type": "module",
"license": "MIT",
"scripts": {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.19",
"version": "1.17.18",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -73,7 +73,7 @@
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
@@ -108,7 +108,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.0",
"gitlab-ai-provider": "6.10.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
-2
View File
@@ -128,8 +128,6 @@ export const fffLayer = Layer.effect(
Fff.create({
basePath: location.directory,
aiMode: true,
disableMmapCache: true,
disableContentIndexing: true,
}),
catch: (cause) => cause,
}).pipe(
-16
View File
@@ -44,21 +44,6 @@ const Cost = Schema.Struct({
),
})
const ReasoningOption = Schema.Union([
Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.NullOr(Schema.String)),
}),
Schema.Struct({
type: Schema.Literal("toggle"),
}),
Schema.Struct({
type: Schema.Literal("budget_tokens"),
min: Schema.optional(Schema.Finite),
max: Schema.optional(Schema.Finite),
}),
])
export const Model = Schema.Struct({
id: Schema.String,
name: Schema.String,
@@ -68,7 +53,6 @@ export const Model = Schema.Struct({
reasoning: Schema.Boolean,
temperature: Schema.Boolean,
tool_call: Schema.Boolean,
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
interleaved: Schema.optional(
Schema.Union([
Schema.Literal(true),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.17.19",
"version": "1.17.18",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
-10
View File
@@ -184,16 +184,6 @@ export function registerIpcHandlers(deps: Deps) {
})
})
ipcMain.handle("reveal-path", async (_event: IpcMainInvokeEvent, path: string) => {
const exists = await stat(path).then(
() => true,
() => false,
)
if (!exists) return false
shell.showItemInFolder(path)
return true
})
ipcMain.handle("read-clipboard-image", () => {
const image = clipboard.readImage()
if (image.isEmpty()) return null
-1
View File
@@ -95,7 +95,6 @@ const api: ElectronAPI = {
saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
openLink: (url) => ipcRenderer.send("open-link", url),
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
revealPath: (path) => ipcRenderer.invoke("reveal-path", path),
readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"),
showNotification: (title, body) => ipcRenderer.send("show-notification", title, body),
getWindowFocused: () => ipcRenderer.invoke("get-window-focused"),
-1
View File
@@ -86,7 +86,6 @@ export type ElectronAPI = {
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
openLink: (url: string) => void
openPath: (path: string, app?: string) => Promise<void>
revealPath: (path: string) => Promise<boolean>
readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
showNotification: (title: string, body?: string) => void
getWindowFocused: () => Promise<boolean>
-3
View File
@@ -218,9 +218,6 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
}
return window.api.openPath(path, app)
},
async revealPath(path: string) {
return window.api.revealPath(path)
},
back() {
window.history.back()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.19",
"version": "1.17.18",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.19",
"version": "1.17.18",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.17.19",
"version": "1.17.18",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.17.19",
"version": "1.17.18",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.19",
"version": "1.17.18",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.19",
"version": "1.17.18",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.19",
"version": "1.17.18",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -67,7 +67,7 @@
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
@@ -120,7 +120,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.0",
"gitlab-ai-provider": "6.10.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
+1 -13
View File
@@ -348,18 +348,6 @@ const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpCl
return yield* fetchOrgs(account.url, accessToken)
})
const remove = Effect.fn("Account.remove")(function* (accountID: AccountID) {
const active = yield* repo.active()
yield* repo.remove(accountID)
if (Option.isNone(active) || active.value.id !== accountID) return
const next = (yield* orgsByAccount()).flatMap((group) =>
group.orgs.map((org) => ({ accountID: group.account.id, orgID: org.id })),
)[0]
if (!next) return
yield* repo.use(next.accountID, Option.some(next.orgID))
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
@@ -457,7 +445,7 @@ const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpCl
activeOrg,
list: repo.list,
orgsByAccount,
remove,
remove: repo.remove,
use: repo.use,
orgs,
config,
+1 -73
View File
@@ -6,15 +6,12 @@ import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http"
import { OpenAIWebSocketPool } from "./ws-pool"
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { isRecord } from "@/util/record"
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
const ISSUER = "https://auth.openai.com"
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
const OAUTH_PORT = 1455
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
const CODEX_COMPATIBILITY_VERSION = "0.144.0"
const RESPONSES_LITE_MODEL = "gpt-5.6-luna"
const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"])
@@ -286,10 +283,8 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
return Object.fromEntries(
Object.entries(provider.models)
.filter(([, model]) => {
if (model.options.reasoningMode === "pro") return false
if (ALLOWED_MODELS.has(model.api.id)) return true
if (DISALLOWED_MODELS.has(model.api.id)) return false
if (model.api.id === "gpt-5.6") return false
const match = model.api.id.match(/^gpt-(\d+\.\d+)/)
return match ? parseFloat(match[1]) > 5.4 : false
})
@@ -308,13 +303,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
input: 272_000,
output: 128_000,
}
: model.id.includes("gpt-5.6")
? {
context: 500_000,
input: 372_000,
output: 128_000,
}
: model.limit,
: model.limit,
},
]),
)
@@ -421,9 +410,6 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
const requestInit = {
...init,
body: parsed.pathname.endsWith("/responses")
? prepareResponsesLiteRequest(init?.body, headers)
: init?.body,
headers,
}
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
@@ -568,61 +554,3 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
},
}
}
function prepareResponsesLiteRequest(body: BodyInit | null | undefined, headers: Headers) {
if (typeof body !== "string") return body
const request: unknown = JSON.parse(body)
if (!isRecord(request)) return body
if (request.model !== RESPONSES_LITE_MODEL) return body
if (!Array.isArray(request.input)) throw new Error("Responses Lite requires an input array")
if (request.tools !== undefined && !Array.isArray(request.tools)) {
throw new Error("Responses Lite requires a tools array")
}
if (request.instructions !== undefined && typeof request.instructions !== "string") {
throw new Error("Responses Lite requires string instructions")
}
const sessionID = headers.get("session-id")
if (!sessionID) throw new Error("Responses Lite requires a session-id header")
function stripImageDetail(value: unknown) {
if (Array.isArray(value)) {
value.forEach(stripImageDetail)
return
}
if (!isRecord(value)) return
if (value.type === "input_image") delete value.detail
Object.values(value).forEach(stripImageDetail)
}
stripImageDetail(request.input)
request.input = [
{ type: "additional_tools", role: "developer", tools: request.tools ?? [] },
...(request.instructions
? [
{
type: "message",
role: "developer",
content: [{ type: "input_text", text: request.instructions }],
},
]
: []),
...request.input,
]
delete request.tools
delete request.instructions
request.tool_choice = "auto"
request.parallel_tool_calls = false
request.prompt_cache_key = sessionID
request.reasoning = {
...(isRecord(request.reasoning) ? request.reasoning : {}),
context: "all_turns",
}
headers.set("session-id", sessionID)
headers.set("x-session-affinity", sessionID)
headers.set("version", CODEX_COMPATIBILITY_VERSION)
headers.set(OpenAIWebSocketPool.RESPONSES_LITE_HEADER, "true")
headers.delete("content-length")
return JSON.stringify(request)
}
+1 -12
View File
@@ -4,8 +4,6 @@ import { isRecord } from "@/util/record"
import { OpenAIWebSocket } from "./ws"
export const TITLE_HEADER = "x-opencode-title"
export const RESPONSES_LITE_HEADER = "x-openai-internal-codex-responses-lite"
const RESPONSES_LITE_CLIENT_METADATA = "ws_request_header_x_openai_internal_codex_responses_lite"
export interface CreateWebSocketFetchOptions {
httpFetch?: typeof globalThis.fetch
@@ -100,16 +98,7 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
})
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket: entry.socket,
body:
internalHeaders[RESPONSES_LITE_HEADER] === "true"
? {
...body,
client_metadata: {
...(isRecord(body.client_metadata) ? body.client_metadata : {}),
[RESPONSES_LITE_CLIENT_METADATA]: "true",
},
}
: body,
body,
idleTimeout,
signal: init?.signal ?? undefined,
onFirstEvent: (error) => resolveFirstEvent(error ?? true),
+11 -21
View File
@@ -1247,11 +1247,9 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
variants: {},
}
const variants = ProviderTransform.reasoningVariants(model, base) ?? ProviderTransform.variants(base)
return {
...base,
variants: mapValues(variants, (v) => v),
variants: mapValues(ProviderTransform.variants(base), (v) => v),
}
}
@@ -1267,7 +1265,14 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
id: ModelV2.ID.make(id),
name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`,
cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost,
options: modeOptions(base, opts.provider?.body),
options: opts.provider?.body
? Object.fromEntries(
Object.entries(opts.provider.body).map(([k, v]) => [
k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()),
v,
]),
)
: base.options,
headers: opts.provider?.headers ?? base.headers,
}
}
@@ -1282,17 +1287,6 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
}
}
function modeOptions(model: Model, body: Record<string, unknown> | undefined) {
if (!body) return model.options
const options = Object.fromEntries(
Object.entries(body).map(([key, value]) => [key.replace(/_([a-z])/g, (_, char) => char.toUpperCase()), value]),
)
const reasoning = body.reasoning
if (model.api.npm !== "@ai-sdk/openai" || !isRecord(reasoning) || typeof reasoning.mode !== "string") return options
const { reasoning: _, ...rest } = options
return { ...rest, reasoningMode: reasoning.mode }
}
function modelSuggestions(provider: Info | undefined, modelID: ModelV2.ID, enableExperimentalModels: boolean) {
const available = provider
? Object.keys(provider.models).filter((id) => {
@@ -1498,11 +1492,7 @@ const layer = Layer.effect(
release_date: model.release_date ?? existingModel?.release_date ?? "",
variants: {},
}
const variants =
existingModel?.api.npm === parsedModel.api.npm
? (existingModel.variants ?? ProviderTransform.variants(parsedModel))
: ProviderTransform.variants(parsedModel)
const merged = mergeDeep(variants, model.variants ?? {})
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
parsedModel.variants = mapValues(
pickBy(merged, (v) => !v.disabled),
(v) => omit(v, ["disabled"]),
@@ -1630,7 +1620,7 @@ const layer = Layer.effect(
)
delete provider.models[modelID]
if (model.variants === undefined) {
if (!model.variants || Object.keys(model.variants).length === 0) {
model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
}
+6 -189
View File
@@ -759,7 +759,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
}
case "@ai-sdk/gateway":
if (model.api.id.includes("anthropic")) {
if (model.id.includes("anthropic")) {
if (adaptiveEfforts) {
return Object.fromEntries(
adaptiveEfforts.map((effort) => [
@@ -792,8 +792,8 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
},
}
}
if (model.api.id.includes("google")) {
if (model.api.id.includes("2.5")) {
if (model.id.includes("google")) {
if (id.includes("2.5")) {
return {
high: {
thinkingConfig: {
@@ -804,7 +804,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
max: {
thinkingConfig: {
includeThoughts: true,
thinkingBudget: googleThinkingBudgetMax(model.api.id.toLowerCase()),
thinkingBudget: googleThinkingBudgetMax(id),
},
},
}
@@ -1102,8 +1102,7 @@ export function options(input: {
input.model.providerID === "openai" ||
input.model.api.npm === "@ai-sdk/openai" ||
input.model.api.npm === "@ai-sdk/github-copilot" ||
input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" ||
input.model.api.npm === "@ai-sdk/xai"
input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle"
) {
result["store"] = false
}
@@ -1258,8 +1257,7 @@ export function smallOptions(model: Provider.Model) {
if (
model.providerID === "openai" ||
model.api.npm === "@ai-sdk/openai" ||
model.api.npm === "@ai-sdk/github-copilot" ||
model.api.npm === "@ai-sdk/xai"
model.api.npm === "@ai-sdk/github-copilot"
) {
const base = { store: false }
return mergeDeep(base, small)
@@ -1580,185 +1578,4 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7
return schema
}
export function reasoningVariants(model: ModelsDev.Model, target: Provider.Model): Provider.Model["variants"] {
const options = model.reasoning_options
if (options === undefined) return
if (options.length === 0) return {}
const effort = options.find((option) => option.type === "effort")
if (effort) return nonEmptyVariants(effortVariants(target, effort.values))
const toggle = options.some((option) => option.type === "toggle")
const budget = options.find((option) => option.type === "budget_tokens")
if (!budget) return toggle ? nonEmptyVariants(reasoningToggle(target)) : undefined
return nonEmptyVariants({
...(toggle ? reasoningToggle(target) : {}),
...budgetVariants(target, budget.min, budget.max),
})
}
function effortVariants(model: Provider.Model, values: readonly unknown[]) {
return Object.fromEntries(
values.flatMap((value) => {
const id = (() => {
if (value === null) return "none"
if (typeof value === "string") return value
})()
if (id === undefined) return []
const settings = reasoningEffort(model, id)
return settings ? [[id, settings]] : []
}),
)
}
function budgetVariants(model: Provider.Model, min?: number, max?: number) {
const maximum = Math.min(max ?? OUTPUT_TOKEN_MAX - 1, model.limit.output - 1, OUTPUT_TOKEN_MAX - 1)
if (maximum <= 0) return {}
const high = Math.min(Math.max(min ?? 0, Math.floor((maximum + 1) / 2)), maximum)
return Object.fromEntries(
[
{ id: "high", budget: high },
{ id: "max", budget: maximum },
].flatMap((item) => {
const settings = reasoningBudget(model, item.budget)
return settings ? [[item.id, settings]] : []
}),
)
}
function nonEmptyVariants(variants: NonNullable<Provider.Model["variants"]>): Provider.Model["variants"] {
return Object.keys(variants).length > 0 ? variants : undefined
}
function reasoningToggle(model: Provider.Model): NonNullable<Provider.Model["variants"]> {
if (model.api.npm === "@ai-sdk/alibaba")
return {
none: { enableThinking: false },
high: { enableThinking: true },
}
if (model.api.npm === "@ai-sdk/cohere")
return {
none: { thinking: { type: "disabled" } },
high: { thinking: { type: "enabled" } },
}
return {}
}
function reasoningEffort(model: Provider.Model, effort: string) {
switch (model.api.npm) {
case "@openrouter/ai-sdk-provider":
return { reasoning: { effort } }
case "@ai-sdk/anthropic":
case "@ai-sdk/google-vertex/anthropic":
return anthropicEffort(model, effort)
case "@ai-sdk/google":
case "@ai-sdk/google-vertex":
return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
case "@ai-sdk/amazon-bedrock":
if (anthropicAdaptiveEfforts(model.api.id))
return {
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: effort,
...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}),
},
}
if (model.api.id.includes("anthropic")) return
return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } }
case "@ai-sdk/gateway":
if (model.id.includes("anthropic")) return { thinking: { type: "adaptive", display: "summarized" }, effort }
if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
return { reasoningEffort: effort }
case "@ai-sdk/github-copilot":
// OAuth discovery replaces these with variants from Copilot's /models capabilities.
if (model.id.includes("gemini")) return
if (model.id.includes("claude")) return { reasoningEffort: effort }
return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING }
case "@ai-sdk/openai":
case "@ai-sdk/amazon-bedrock/mantle":
return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING }
case "@ai-sdk/azure":
return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING }
case "@jerome-benoit/sap-ai-provider-v2":
if (model.id.includes("anthropic"))
return { modelParams: { thinking: { type: "adaptive", display: "summarized" }, output_config: { effort } } }
return { modelParams: { reasoning_effort: effort } }
case "@ai-sdk/openai-compatible":
case "@ai-sdk/xai":
case "@ai-sdk/mistral":
case "@ai-sdk/groq":
case "@ai-sdk/cerebras":
case "@ai-sdk/deepinfra":
case "@ai-sdk/togetherai":
case "venice-ai-sdk-provider":
case "ai-gateway-provider":
return { reasoningEffort: effort }
case "@ai-sdk/cohere":
case "@ai-sdk/perplexity":
case "@ai-sdk/vercel":
case "@ai-sdk/alibaba":
case "gitlab-ai-provider":
return
}
}
function anthropicEffort(model: Provider.Model, effort: string) {
if (["opus-4-5", "opus-4.5"].some((value) => model.api.id.includes(value))) return { effort }
if (!anthropicAdaptiveEfforts(model.api.id)) return
return {
thinking: {
type: "adaptive",
...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}),
},
effort,
}
}
function reasoningBudget(model: Provider.Model, budget: number) {
switch (model.api.npm) {
case "@openrouter/ai-sdk-provider":
return { reasoning: { max_tokens: budget } }
case "@ai-sdk/anthropic":
case "@ai-sdk/google-vertex/anthropic":
return { thinking: { type: "enabled", budgetTokens: budget } }
case "@ai-sdk/google":
case "@ai-sdk/google-vertex":
return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } }
case "@ai-sdk/amazon-bedrock":
return { reasoningConfig: { type: "enabled", budgetTokens: budget } }
case "@ai-sdk/gateway":
if (model.id.includes("anthropic")) return { thinking: { type: "enabled", budgetTokens: budget } }
if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } }
return
case "@ai-sdk/cohere":
return { thinking: { type: "enabled", tokenBudget: budget } }
case "@ai-sdk/alibaba":
return { enableThinking: true, thinkingBudget: budget }
case "@jerome-benoit/sap-ai-provider-v2":
if (model.id.includes("anthropic"))
return { modelParams: { thinking: { type: "enabled", budget_tokens: budget } } }
if (model.id.includes("gemini"))
return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } }
return
case "@ai-sdk/amazon-bedrock/mantle":
case "@ai-sdk/azure":
case "@ai-sdk/cerebras":
case "@ai-sdk/deepinfra":
case "@ai-sdk/github-copilot":
case "@ai-sdk/groq":
case "@ai-sdk/mistral":
case "@ai-sdk/openai":
case "@ai-sdk/openai-compatible":
case "@ai-sdk/perplexity":
case "@ai-sdk/togetherai":
case "@ai-sdk/vercel":
case "@ai-sdk/xai":
case "ai-gateway-provider":
case "gitlab-ai-provider":
case "venice-ai-sdk-provider":
return
}
}
export * as ProviderTransform from "./transform"
@@ -173,53 +173,6 @@ it.live("orgsByAccount groups orgs per account", () =>
}),
)
it.live("remove switches to another org when the active account is removed", () =>
Effect.gen(function* () {
const first = AccountID.make("user-1")
const second = AccountID.make("user-2")
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: first,
email: "one@example.com",
url: "https://one.example.com",
accessToken: AccessToken.make("at_1"),
refreshToken: RefreshToken.make("rt_1"),
expiry: Date.now() + outsideEagerRefreshWindow,
orgID: Option.some(OrgID.make("org-1")),
}),
)
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: second,
email: "two@example.com",
url: "https://two.example.com",
accessToken: AccessToken.make("at_2"),
refreshToken: RefreshToken.make("rt_2"),
expiry: Date.now() + outsideEagerRefreshWindow,
orgID: Option.some(OrgID.make("org-2")),
}),
)
const client = HttpClient.make((req) =>
Effect.succeed(
req.url === "https://one.example.com/api/orgs" ? json(req, [org("org-1", "One")]) : json(req, [], 404),
),
)
yield* Account.use.remove(second).pipe(Effect.provide(live(client)))
const active = yield* AccountRepo.use.active()
expect(Option.getOrThrow(active)).toEqual(
expect.objectContaining({
id: first,
active_org_id: OrgID.make("org-1"),
}),
)
}),
)
it.live("token refresh persists the new token", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
@@ -149,48 +149,6 @@ describe("plugin.codex", () => {
await enabled.dispose?.()
})
test("filters unsupported modes and uses Codex context limits for OAuth GPT models", async () => {
const hooks = await CodexAuthPlugin({} as never)
const limit = { context: 1_050_000, input: 922_000, output: 128_000 }
const provider = {
models: {
...Object.fromEntries(
["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.7-pro"].map(
(id) => [id, { id, api: { id }, limit, cost: {}, options: {} }],
),
),
"gpt-5.4-pro": {
id: "gpt-5.4-pro",
api: { id: "gpt-5.4" },
limit,
cost: {},
options: { reasoningMode: "pro" },
},
"gpt-5.6-sol-high": {
id: "gpt-5.6-sol-high",
api: { id: "gpt-5.6-sol" },
limit,
cost: {},
options: { reasoningEffort: "high" },
},
},
}
const models = await hooks.provider!.models!(provider as never, { auth: { type: "oauth" } } as never)
expect(models["gpt-5.4"]?.limit).toEqual(limit)
expect(models["gpt-5.5"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(models["gpt-5.6-sol"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
expect(models["gpt-5.6-terra"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
expect(models["gpt-5.6-luna"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
expect(models["gpt-5.4-pro"]).toBeUndefined()
expect(models["gpt-5.7-pro"]).toBeDefined()
expect(models["gpt-5.6-sol-high"]).toBeDefined()
expect(await hooks.provider!.models!(provider as never, { auth: { type: "api" } } as never)).toBe(
provider.models as never,
)
})
test("deduplicates concurrent Codex token refreshes", async () => {
let auth = {
type: "oauth" as const,
+33 -145
View File
@@ -159,10 +159,10 @@ it.instance(
const providers = yield* list
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderV2.ID.anthropic].models)
expect(models).toContain("claude-sonnet-4-6")
expect(models).toContain("claude-sonnet-4-20250514")
expect(models.length).toBe(1)
}),
{ config: { provider: { anthropic: { whitelist: ["claude-sonnet-4-6"] } } } },
{ config: { provider: { anthropic: { whitelist: ["claude-sonnet-4-20250514"] } } } },
)
it.instance(
@@ -301,10 +301,10 @@ it.instance("getModel returns model for valid provider/model", () =>
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6"))
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
expect(model).toBeDefined()
expect(String(model.providerID)).toBe("anthropic")
expect(String(model.id)).toBe("claude-sonnet-4-6")
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
const language = yield* provider.getLanguage(model)
expect(language).toBeDefined()
}),
@@ -435,7 +435,7 @@ it.instance(
"model options are merged from existing model",
Effect.gen(function* () {
const providers = yield* list
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.options.customOption).toBe("custom-value")
}),
{
@@ -443,7 +443,7 @@ it.instance(
provider: {
anthropic: {
options: { apiKey: "test-api-key" },
models: { "claude-sonnet-4-6": { options: { customOption: "custom-value" } } },
models: { "claude-sonnet-4-20250514": { options: { customOption: "custom-value" } } },
},
},
},
@@ -549,7 +549,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.name).toBe("Custom Name for Sonnet")
expect(model.capabilities.toolcall).toBe(true)
expect(model.capabilities.attachment).toBe(true)
@@ -557,46 +557,7 @@ it.instance(
}),
{
config: {
provider: { anthropic: { models: { "claude-sonnet-4-6": { name: "Custom Name for Sonnet" } } } },
},
},
)
it.instance(
"model config preserves explicitly empty models.dev variants",
Effect.gen(function* () {
yield* set("OPENAI_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderV2.ID.openai].models["custom-gpt-chat"]
expect(model.name).toBe("Custom GPT Chat")
expect(model.variants).toEqual({})
}),
{
config: {
provider: {
openai: { models: { "custom-gpt-chat": { id: "gpt-5-chat-latest", name: "Custom GPT Chat" } } },
},
},
},
)
it.instance(
"model config regenerates variants when overriding the provider package",
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
expect(model.variants?.low).toEqual({ reasoningEffort: "low" })
expect(model.variants?.max).toBeUndefined()
}),
{
config: {
provider: {
anthropic: {
npm: "@ai-sdk/openai-compatible",
models: { "claude-sonnet-4-6": { name: "Claude via OpenAI" } },
},
},
provider: { anthropic: { models: { "claude-sonnet-4-20250514": { name: "Custom Name for Sonnet" } } } },
},
},
)
@@ -629,16 +590,16 @@ it.instance(
const providers = yield* list
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderV2.ID.anthropic].models)
expect(models).toContain("claude-sonnet-4-6")
expect(models).not.toContain("claude-opus-4-6")
expect(models).toContain("claude-sonnet-4-20250514")
expect(models).not.toContain("claude-opus-4-20250514")
expect(models.length).toBe(1)
}),
{
config: {
provider: {
anthropic: {
whitelist: ["claude-sonnet-4-6", "claude-opus-4-6"],
blacklist: ["claude-opus-4-6"],
whitelist: ["claude-sonnet-4-20250514", "claude-opus-4-20250514"],
blacklist: ["claude-opus-4-20250514"],
},
},
},
@@ -812,9 +773,9 @@ it.instance(
const model = yield* Provider.use.getSmallModel(ProviderV2.ID.anthropic)
expect(model).toBeDefined()
expect(String(model?.providerID)).toBe("anthropic")
expect(String(model?.id)).toBe("claude-sonnet-4-6")
expect(String(model?.id)).toBe("claude-sonnet-4-20250514")
}),
{ config: { small_model: "anthropic/claude-sonnet-4-6" } },
{ config: { small_model: "anthropic/claude-sonnet-4-20250514" } },
)
it.instance(
@@ -1133,8 +1094,8 @@ it.instance(
it.instance("getModel returns consistent results", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const model1 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6"))
const model2 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6"))
const model1 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
const model2 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
expect(model1.providerID).toEqual(model2.providerID)
expect(model1.id).toEqual(model2.id)
expect(model1).toEqual(model2)
@@ -1376,17 +1337,16 @@ it.instance(
},
)
test("mode options and cost are derived from the base model", () => {
test("mode cost preserves over-200k pricing from base model", () => {
const provider = {
id: "openai",
name: "OpenAI",
env: [],
npm: "@ai-sdk/openai",
api: "https://api.openai.com/v1",
models: {
"gpt-5.6-sol": {
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
"gpt-5.4": {
id: "gpt-5.4",
name: "GPT-5.4",
family: "gpt",
release_date: "2026-03-05",
attachment: true,
@@ -1422,29 +1382,18 @@ test("mode options and cost are derived from the base model", () => {
},
},
},
pro: {
provider: {
body: {
reasoning: { mode: "pro" },
service_tier: "priority",
},
},
},
},
},
},
},
} as unknown as ModelsDev.Provider
const model = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-fast"]
const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"]
expect(model.cost.input).toEqual(5)
expect(model.cost.output).toEqual(30)
expect(model.cost.cache.read).toEqual(0.5)
expect(model.cost.cache.write).toEqual(0)
expect(model.options["serviceTier"]).toEqual("priority")
const pro = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-pro"]
expect(pro.api.id).toEqual("gpt-5.6-sol")
expect(pro.options).toEqual({ reasoningMode: "pro", serviceTier: "priority" })
expect(model.cost.experimentalOver200K).toEqual({
input: 5,
output: 22.5,
@@ -1477,62 +1426,6 @@ test("models.dev normalization fills required response fields", () => {
expect(model.release_date).toBe("")
})
test("models.dev reasoning options replace generated variants and unsupported options fall back", () => {
const provider = {
id: "reasoning",
name: "Reasoning",
env: [],
npm: "@ai-sdk/openai",
models: {
explicit: {
id: "gpt-5.4",
name: "Explicit",
reasoning: true,
reasoning_options: [{ type: "effort", values: ["low"] }],
limit: { context: 128_000, output: 64_000 },
},
empty: {
id: "gpt-5.4",
name: "Empty",
reasoning: true,
reasoning_options: [],
limit: { context: 128_000, output: 64_000 },
},
fallback: {
id: "gpt-5.4",
name: "Fallback",
reasoning: true,
reasoning_options: [{ type: "toggle" }],
limit: { context: 128_000, output: 64_000 },
},
override: {
id: "gemini-3-pro",
name: "Override",
reasoning: true,
reasoning_options: [{ type: "effort", values: ["high"] }],
provider: { npm: "@ai-sdk/google" },
limit: { context: 128_000, output: 64_000 },
experimental: { modes: { fast: {} } },
},
},
} as unknown as ModelsDev.Provider
const models = Provider.fromModelsDevProvider(provider).models
expect(models.explicit.variants).toEqual({
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
expect(models.empty.variants).toEqual({})
expect(Object.keys(models.fallback.variants ?? {})).toEqual(["none", "low", "medium", "high", "xhigh"])
expect(models.override.variants).toEqual({
high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } },
})
expect(models["gemini-3-pro-fast"].variants).toEqual(models.override.variants)
})
test("public provider info omits invalid models", () => {
const provider = Provider.fromModelsDevProvider({
id: "test",
@@ -1564,7 +1457,7 @@ it.instance("model variants are generated for reasoning models", () =>
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
// Claude sonnet 4 has reasoning capability
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.capabilities.reasoning).toBe(true)
expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBeGreaterThan(0)
@@ -1576,7 +1469,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined()
// max variant should still exist
@@ -1586,7 +1479,7 @@ it.instance(
config: {
provider: {
anthropic: {
models: { "claude-sonnet-4-6": { variants: { high: { disabled: true } } } },
models: { "claude-sonnet-4-20250514": { variants: { high: { disabled: true } } } },
},
},
},
@@ -1598,7 +1491,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
}),
@@ -1607,7 +1500,7 @@ it.instance(
provider: {
anthropic: {
models: {
"claude-sonnet-4-6": {
"claude-sonnet-4-20250514": {
variants: { high: { thinking: { type: "enabled", budgetTokens: 20000 } } },
},
},
@@ -1622,7 +1515,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["max"]).toBeDefined()
expect(model.variants!["max"].disabled).toBeUndefined()
expect(model.variants!["max"].customField).toBe("test")
@@ -1632,7 +1525,7 @@ it.instance(
provider: {
anthropic: {
models: {
"claude-sonnet-4-6": {
"claude-sonnet-4-20250514": {
variants: { max: { disabled: false, customField: "test" } },
},
},
@@ -1647,7 +1540,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBe(0)
}),
@@ -1656,13 +1549,8 @@ it.instance(
provider: {
anthropic: {
models: {
"claude-sonnet-4-6": {
variants: {
low: { disabled: true },
medium: { disabled: true },
high: { disabled: true },
max: { disabled: true },
},
"claude-sonnet-4-20250514": {
variants: { high: { disabled: true }, max: { disabled: true } },
},
},
},
@@ -1676,7 +1564,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
// Should have both the generated thinking config and the custom option
expect(model.variants!["high"].thinking).toBeDefined()
@@ -1687,7 +1575,7 @@ it.instance(
provider: {
anthropic: {
models: {
"claude-sonnet-4-6": { variants: { high: { extraOption: "custom-value" } } },
"claude-sonnet-4-20250514": { variants: { high: { extraOption: "custom-value" } } },
},
},
},
@@ -4,7 +4,6 @@ import { ProviderTransform } from "@/provider/transform"
import { LLMRequestPrep } from "@/session/llm/request"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { jsonSchema } from "ai"
describe("ProviderTransform.options - setCacheKey", () => {
@@ -156,43 +155,6 @@ describe("ProviderTransform.options - setCacheKey", () => {
expect(result.store).toBe(false)
})
test("should set store=false for xAI provider by default", () => {
const xaiModel = {
...mockModel,
providerID: "xai",
api: {
id: "grok-4",
url: "https://api.x.ai",
npm: "@ai-sdk/xai",
},
}
const result = ProviderTransform.options({
model: xaiModel,
sessionID,
providerOptions: {},
})
expect(result.store).toBe(false)
expect(result.promptCacheKey).toBe(sessionID)
})
test("should set store=false for xAI SDK regardless of provider ID", () => {
const xaiModel = {
...mockModel,
providerID: "custom-xai",
api: {
id: "grok-4",
url: "https://api.x.ai",
npm: "@ai-sdk/xai",
},
}
const result = ProviderTransform.options({
model: xaiModel,
sessionID,
providerOptions: {},
})
expect(result.store).toBe(false)
})
test("should set store=false for azure provider by default", () => {
const azureModel = {
...mockModel,
@@ -3026,268 +2988,6 @@ describe("ProviderTransform.temperature - Cohere North", () => {
})
})
describe("ProviderTransform.reasoningVariants", () => {
const model = (reasoning_options: ModelsDev.Model["reasoning_options"]) => ({ reasoning_options }) as ModelsDev.Model
const target = (npm: string, id = "test-model") =>
({ id, api: { id, npm, url: "" }, capabilities: { reasoning: true }, limit: { output: 64_000 } }) as any
test("respects explicitly empty reasoning options", () => {
expect(ProviderTransform.reasoningVariants(model([]), target("@ai-sdk/openai"))).toEqual({})
})
test.each([
["@openrouter/ai-sdk-provider", { reasoning: { effort: "high" } }],
["@ai-sdk/anthropic", { thinking: { type: "adaptive" }, effort: "high" }, "claude-opus-4-6"],
[
"@ai-sdk/google-vertex/anthropic",
{ thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
"claude-opus-4-7",
],
["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }],
["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }],
[
"@ai-sdk/azure",
{
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
],
[
"@ai-sdk/openai",
{
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
],
[
"@ai-sdk/amazon-bedrock/mantle",
{
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
],
[
"@ai-sdk/github-copilot",
{
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
],
["@ai-sdk/openai-compatible", { reasoningEffort: "high" }],
["@ai-sdk/xai", { reasoningEffort: "high" }],
["@ai-sdk/mistral", { reasoningEffort: "high" }],
["@ai-sdk/groq", { reasoningEffort: "high" }],
["@ai-sdk/cerebras", { reasoningEffort: "high" }],
["@ai-sdk/deepinfra", { reasoningEffort: "high" }],
["@ai-sdk/togetherai", { reasoningEffort: "high" }],
["venice-ai-sdk-provider", { reasoningEffort: "high" }],
["ai-gateway-provider", { reasoningEffort: "high" }],
["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }],
])("converts effort for %s", (npm, expected, ...args) => {
const id = args[0] as string | undefined
expect(ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target(npm, id))).toEqual(
{ high: expected },
)
})
test("uses bare effort for Claude Opus 4.5", () => {
expect(
ProviderTransform.reasoningVariants(
model([{ type: "effort", values: ["high"] }]),
target("@ai-sdk/anthropic", "claude-opus-4-5"),
),
).toEqual({ high: { effort: "high" } })
})
test("leaves legacy Anthropic effort options to budget fallback", () => {
expect(
ProviderTransform.reasoningVariants(
model([{ type: "effort", values: ["high"] }]),
target("@ai-sdk/anthropic", "claude-sonnet-4"),
),
).toBeUndefined()
})
test("uses adaptive reasoning config for Anthropic models on Bedrock", () => {
expect(
ProviderTransform.reasoningVariants(
model([{ type: "effort", values: ["high"] }]),
target("@ai-sdk/amazon-bedrock", "anthropic.claude-opus-4-7-v1:0"),
),
).toEqual({
high: {
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: "high",
display: "summarized",
},
},
})
})
test("leaves legacy Anthropic Bedrock effort options to budget fallback", () => {
expect(
ProviderTransform.reasoningVariants(
model([{ type: "effort", values: ["high"] }]),
target("@ai-sdk/amazon-bedrock", "anthropic.claude-sonnet-4-v1:0"),
),
).toBeUndefined()
})
test.each([
["@openrouter/ai-sdk-provider", { reasoning: { max_tokens: 16_000 } }],
["@ai-sdk/anthropic", { thinking: { type: "enabled", budgetTokens: 16_000 } }],
["@ai-sdk/google-vertex/anthropic", { thinking: { type: "enabled", budgetTokens: 16_000 } }],
["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }],
["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }],
["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", budgetTokens: 16_000 } }],
["@ai-sdk/cohere", { thinking: { type: "enabled", tokenBudget: 16_000 } }],
["@ai-sdk/alibaba", { enableThinking: true, thinkingBudget: 16_000 }],
])("converts token budgets for %s", (npm, high) => {
const variants = ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024 }]), target(npm))
expect(variants?.high).toEqual(high)
expect(Object.keys(variants ?? {})).toEqual(["high", "max"])
})
test("maps null effort to none", () => {
expect(
ProviderTransform.reasoningVariants(model([{ type: "effort", values: [null] }]), target("@ai-sdk/openai")),
).toEqual({
none: {
reasoningEffort: "none",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
})
test.each([
["@ai-sdk/alibaba", { none: { enableThinking: false }, high: { enableThinking: true } }],
[
"@ai-sdk/cohere",
{
none: { thinking: { type: "disabled" } },
high: { thinking: { type: "enabled" } },
},
],
])("converts toggle options for %s", (npm, expected) => {
expect(ProviderTransform.reasoningVariants(model([{ type: "toggle" }]), target(npm))).toEqual(expected)
})
test("combines Cohere toggle and budget options", () => {
expect(
ProviderTransform.reasoningVariants(
model([{ type: "toggle" }, { type: "budget_tokens", min: 1 }]),
target("@ai-sdk/cohere"),
),
).toEqual({
none: { thinking: { type: "disabled" } },
high: { thinking: { type: "enabled", tokenBudget: 16_000 } },
max: { thinking: { type: "enabled", tokenBudget: 31_999 } },
})
})
test("generates bounded high and max token budgets", () => {
expect(
ProviderTransform.reasoningVariants(
model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]),
target("@ai-sdk/anthropic"),
),
).toEqual({
high: { thinking: { type: "enabled", budgetTokens: 16_000 } },
max: { thinking: { type: "enabled", budgetTokens: 31_999 } },
})
})
test("caps token budgets below the model output limit", () => {
const anthropic = target("@ai-sdk/anthropic")
anthropic.limit.output = 5_000
expect(
ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]), anthropic),
).toEqual({
high: { thinking: { type: "enabled", budgetTokens: 2_500 } },
max: { thinking: { type: "enabled", budgetTokens: 4_999 } },
})
})
test("derives high and max budgets when models.dev omits max", () => {
expect(
ProviderTransform.reasoningVariants(
model([{ type: "budget_tokens", min: 1_024 }]),
target("@ai-sdk/anthropic", "claude-haiku-4-5"),
),
).toEqual({
high: { thinking: { type: "enabled", budgetTokens: 16_000 } },
max: { thinking: { type: "enabled", budgetTokens: 31_999 } },
})
})
test("preserves explicit inclusive budget maxima", () => {
expect(
ProviderTransform.reasoningVariants(
model([{ type: "budget_tokens", min: 1_024, max: 24_576 }]),
target("@ai-sdk/google", "gemini-2.5-pro"),
),
).toEqual({
high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 12_288 } },
max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 24_576 } },
})
})
test("prefers effort options over token budgets", () => {
expect(
ProviderTransform.reasoningVariants(
model([
{ type: "budget_tokens", min: 1_024, max: 64_000 },
{ type: "effort", values: ["low"] },
]),
target("@ai-sdk/openai"),
),
).toEqual({
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
})
test("leaves unsupported options for heuristic fallback", () => {
expect(
ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target("@ai-sdk/perplexity")),
).toBeUndefined()
expect(ProviderTransform.reasoningVariants(model([{ type: "toggle" }]), target("@ai-sdk/openai"))).toBeUndefined()
})
test("uses model-family options for gateway and GitHub Copilot", () => {
const effort = model([{ type: "effort", values: ["high"] }])
expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/gateway", "anthropic/claude-sonnet-4"))).toEqual(
{
high: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
},
)
expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/gateway", "google/gemini-3-pro"))).toEqual({
high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } },
})
expect(
ProviderTransform.reasoningVariants(effort, target("@ai-sdk/github-copilot", "gemini-3-pro")),
).toBeUndefined()
})
test.each(["@ai-sdk/cohere", "@ai-sdk/perplexity", "@ai-sdk/vercel", "@ai-sdk/alibaba", "gitlab-ai-provider"])(
"does not invent effort controls for %s",
(npm) => {
expect(
ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target(npm)),
).toBeUndefined()
},
)
})
describe("ProviderTransform.variants", () => {
const createMockModel = (overrides: Partial<any> = {}): any => ({
id: "test/test-model",
@@ -3672,42 +3372,6 @@ describe("ProviderTransform.variants", () => {
})
describe("@ai-sdk/gateway", () => {
test("configured anthropic aliases route by the API ID", () => {
const model = createMockModel({
id: "my-claude",
providerID: "gateway",
api: {
id: "anthropic/claude-sonnet-4-6",
url: "https://gateway.ai",
npm: "@ai-sdk/gateway",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"])
expect(result.high).toEqual({
thinking: {
type: "adaptive",
},
effort: "high",
})
})
test("configured google aliases route by the API ID", () => {
const model = createMockModel({
id: "my-gemini",
providerID: "gateway",
api: {
id: "google/gemini-2.5-pro",
url: "https://gateway.ai",
npm: "@ai-sdk/gateway",
},
})
expect(ProviderTransform.variants(model)).toEqual({
high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } },
max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 32_768 } },
})
})
test("anthropic sonnet 4.6 models return adaptive thinking options", () => {
const model = createMockModel({
id: "anthropic/claude-sonnet-4-6",
+1 -2
View File
@@ -1092,7 +1092,7 @@ describe("session.llm.stream", () => {
const agent = {
name: "test",
mode: "primary",
options: { reasoningMode: "pro" },
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
temperature: 0.2,
} satisfies Agent.Info
@@ -1123,7 +1123,6 @@ describe("session.llm.stream", () => {
expect(body.model).toBe(resolved.api.id)
expect(body.stream).toBe(true)
expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
expect((body.reasoning as { mode?: string } | undefined)?.mode).toBe("pro")
const maxTokens = body.max_output_tokens as number | undefined
expect(maxTokens).toBe(undefined) // match codex cli behavior
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.17.19",
"version": "1.17.18",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.17.19",
"version": "1.17.18",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.17.19",
"version": "1.17.18",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
"version": "1.17.19",
"version": "1.17.18",
"private": true,
"type": "module",
"license": "MIT",
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { FilePart } from "@opencode-ai/sdk/v2"
import { attached, inline, kind, typeLabel } from "./message-file"
import { attached, inline, kind } from "./message-file"
function file(part: Partial<FilePart> = {}): FilePart {
return {
@@ -52,14 +52,4 @@ describe("message-file", () => {
expect(kind(file({ mime: "image/png" }))).toBe("image")
expect(kind(file({ mime: "application/pdf" }))).toBe("file")
})
test("labels attachment types from the basename extension", () => {
expect(typeLabel("list.md", "text/plain")).toBe("Markdown")
expect(typeLabel("/repo/src/main.ts", "text/plain")).toBe("TypeScript")
expect(typeLabel("/tmp/report.pdf", "application/pdf")).toBe("PDF")
expect(typeLabel("notes.xyz", "text/plain")).toBe("XYZ")
expect(typeLabel("/home/user/my.project/Makefile", "text/plain")).toBe("File")
expect(typeLabel(".gitignore", "text/plain")).toBe("File")
expect(typeLabel("/repo/.env", "text/plain")).toBe("File")
})
})
@@ -1,5 +1,3 @@
import { bundledLanguagesInfo } from "shiki"
import { getFilename } from "@opencode-ai/core/util/path"
import type { FilePart } from "@opencode-ai/sdk/v2"
export function attached(part: FilePart) {
@@ -14,22 +12,3 @@ export function inline(part: FilePart) {
export function kind(part: FilePart) {
return part.mime.startsWith("image/") ? "image" : "file"
}
// language metadata only; grammars stay behind shiki's lazy imports
const LANGUAGE_NAMES = new Map<string, string>(
bundledLanguagesInfo.flatMap((info) =>
[info.id, ...(info.aliases ?? [])].map((alias) => [alias, info.name] as [string, string]),
),
)
// attachments carry text/plain for all text files, so the label comes from the extension;
// filename may be an absolute path, so extract the basename before looking for one
export function typeLabel(filename: string, mime: string) {
if (mime === "application/pdf") return "PDF"
const base = getFilename(filename)
// idx 0 is a dotfile like .gitignore, not an extension
const idx = base.lastIndexOf(".")
const suffix = idx <= 0 ? "" : base.slice(idx + 1).toLowerCase()
if (!suffix) return "File"
return LANGUAGE_NAMES.get(suffix) ?? suffix.toUpperCase()
}
@@ -57,20 +57,8 @@
}
&[data-type="image"] {
position: relative;
width: 58px;
height: 46px;
border: none;
/* inset box-shadows do not paint over <img> content, so the hairline is an overlay */
&::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
pointer-events: none;
}
width: 48px;
height: 48px;
}
&[data-type="file"] {
@@ -1335,16 +1323,6 @@ body:not([data-new-layout]) {
&:hover {
border-color: var(--border-strong-base);
}
&[data-type="image"] {
width: 48px;
height: 48px;
border: 1px solid var(--border-weak-base);
&::after {
content: none;
}
}
}
[data-slot="user-message-attachment-name"] {
@@ -47,8 +47,6 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Markdown } from "./markdown"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { AttachmentCardV2 } from "../v2/components/attachment-card-v2"
import { CommentCardV2 } from "../v2/components/comment-card-v2"
import { checksum } from "@opencode-ai/core/util/encode"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -62,7 +60,7 @@ import { ToolStatusTitle } from "./tool-status-title"
import { patchFiles } from "./apply-patch-file"
import { animate } from "motion"
import { useLocation } from "@solidjs/router"
import { attached, inline, kind, typeLabel } from "./message-file"
import { attached, inline, kind } from "./message-file"
import { readPartText } from "./message-part-text"
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
@@ -169,7 +167,6 @@ export interface MessageProps {
showAssistantCopyPartID?: string | null
showReasoningSummaries?: boolean
useV2Actions?: boolean
comments?: UserMessageComment[]
}
export type SessionAction = (input: { sessionID: string; messageID: string }) => Promise<void> | void
@@ -177,16 +174,6 @@ export type SessionAction = (input: { sessionID: string; messageID: string }) =>
export type UserActions = {
fork?: SessionAction
revert?: SessionAction
openAttachment?: (file: FilePart) => void
}
export type UserMessageComment = {
path: string
comment: string
selection?: {
startLine: number
endLine: number
}
}
export interface MessagePartProps {
@@ -959,7 +946,6 @@ export function Message(props: MessageProps) {
parts={props.parts}
actions={props.actions}
useV2Actions={props.useV2Actions}
comments={props.comments}
/>
)}
</Match>
@@ -1177,7 +1163,6 @@ export function UserMessageDisplay(props: {
parts: PartType[]
actions?: UserActions
useV2Actions?: boolean
comments?: UserMessageComment[]
}) {
const data = useData()
const dialog = useDialog()
@@ -1199,8 +1184,6 @@ export function UserMessageDisplay(props: {
const attachments = createMemo(() => files().filter(attached))
const messageComments = createMemo(() => (newLayout() ? (props.comments ?? []) : []))
const inlineFiles = createMemo(() => files().filter(inline))
const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? [])
@@ -1257,59 +1240,35 @@ export function UserMessageDisplay(props: {
return (
<div data-component="user-message" data-timeline-part-id={textPart()?.id}>
<Show when={attachments().length > 0 || messageComments().length > 0}>
<Show when={attachments().length > 0}>
<div data-slot="user-message-attachments">
<For each={messageComments()}>
{(comment) => (
<CommentCardV2
comment={comment.comment}
path={comment.path}
selection={comment.selection}
title={comment.comment}
/>
)}
</For>
<For each={attachments()}>
{(file) => {
const type = kind(file)
const name = file.filename ?? i18n.t("ui.message.attachment.alt")
return (
<Show
when={newLayout() && type === "file"}
fallback={
<div
data-slot="user-message-attachment"
data-type={type}
data-clickable={type === "image" ? "true" : undefined}
title={type === "file" ? name : undefined}
onClick={() => {
if (type === "image") openImagePreview(file.url, name)
}}
>
<Show
when={type === "image"}
fallback={
<div data-slot="user-message-attachment-file">
<FileIcon node={{ path: name, type: "file" }} />
<span data-slot="user-message-attachment-name">{name}</span>
</div>
}
>
<img data-slot="user-message-attachment-image" src={file.url} alt={name} />
</Show>
</div>
}
<div
data-slot="user-message-attachment"
data-type={type}
data-clickable={type === "image" ? "true" : undefined}
title={type === "file" ? name : undefined}
onClick={() => {
if (type === "image") openImagePreview(file.url, name)
}}
>
<AttachmentCardV2
title={getFilename(name)}
hover={name}
clickable={!!props.actions?.openAttachment}
onClick={() => props.actions?.openAttachment?.(file)}
<Show
when={type === "image"}
fallback={
<div data-slot="user-message-attachment-file">
<FileIcon node={{ path: name, type: "file" }} />
<span data-slot="user-message-attachment-name">{name}</span>
</div>
}
>
{typeLabel(name, file.mime)}
</AttachmentCardV2>
</Show>
<img data-slot="user-message-attachment-image" src={file.url} alt={name} />
</Show>
</div>
)
}}
</For>
@@ -1,58 +0,0 @@
[data-component="attachment-card-v2"] {
display: flex;
flex-direction: column;
gap: 6px;
box-sizing: border-box;
width: 160px;
min-width: 160px;
max-width: 160px;
padding: 8px;
border-radius: 6px;
background: var(--v2-overlay-simple-overlay-hover);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
cursor: default;
&[data-active] {
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong);
}
&[data-clickable] {
cursor: pointer;
}
[data-slot="attachment-card-v2-title"],
[data-slot="attachment-card-v2-subtitle"] {
max-width: 100%;
font-family: var(--v2-font-family-sans, "Inter", sans-serif);
font-style: normal;
font-size: 11px;
line-height: 12px;
letter-spacing: 0.05px;
font-variation-settings: "slnt" 0;
}
[data-slot="attachment-card-v2-title"] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 530;
color: var(--v2-text-text-base);
}
[data-slot="attachment-card-v2-subtitle"] {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
white-space: nowrap;
font-weight: 440;
color: var(--v2-text-text-muted);
[data-component="file-icon"] {
width: 12px;
height: 12px;
flex: none;
}
}
}
@@ -1,26 +0,0 @@
import type { JSX } from "solid-js"
import "./attachment-card-v2.css"
/** Shared 160px two-line card used by v2 file and comment attachments in the composer and timeline. */
export function AttachmentCardV2(props: {
title: string
active?: boolean
clickable?: boolean
/** native title attribute */
hover?: string
onClick?: () => void
children: JSX.Element
}) {
return (
<div
data-component="attachment-card-v2"
data-active={props.active ? "true" : undefined}
data-clickable={props.clickable ? "true" : undefined}
title={props.hover}
onClick={() => props.onClick?.()}
>
<span data-slot="attachment-card-v2-title">{props.title}</span>
<span data-slot="attachment-card-v2-subtitle">{props.children}</span>
</div>
)
}
@@ -1,27 +0,0 @@
import { Show } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { getFilenameTruncated } from "@opencode-ai/core/util/path"
import { AttachmentCardV2 } from "./attachment-card-v2"
export function CommentCardV2(props: {
comment: string
path: string
selection?: { startLine: number; endLine: number }
active?: boolean
title?: string
onClick?: () => void
}) {
return (
<AttachmentCardV2 title={props.comment} active={props.active} hover={props.title} onClick={props.onClick}>
<FileIcon node={{ path: props.path, type: "file" }} />
<span>
{getFilenameTruncated(props.path, 14)}
<Show when={props.selection}>
{(sel) =>
sel().startLine === sel().endLine ? `:${sel().startLine}` : `:${sel().startLine}-${sel().endLine}`
}
</Show>
</span>
</AttachmentCardV2>
)
}
@@ -11,7 +11,6 @@ import {
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { cloneSelectedLineRange, formatSelectedLineLabel } from "../../pierre/selection-bridge"
import { LineCommentEditorV2, LineCommentV2 } from "@opencode-ai/ui/v2/line-comment-v2"
import type { LineCommentEditorV2Mention } from "@opencode-ai/ui/v2/line-comment-v2"
type LineCommentControllerV2Props<T extends LineCommentShape> = {
comments: Accessor<T[]>
@@ -24,7 +23,6 @@ type LineCommentControllerV2Props<T extends LineCommentShape> = {
onDelete?: (comment: T) => void
renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element
editSubmitLabel?: string
mention?: LineCommentEditorV2Mention
}
type CommentProps = {
@@ -45,7 +43,6 @@ type DraftProps = {
onSubmit: (value: string) => void
cancelLabel?: string
submitLabel?: string
mention?: LineCommentEditorV2Mention
}
function lineCommentElementV2(view: Accessor<CommentProps>) {
@@ -73,7 +70,6 @@ function lineCommentElementV2(view: Accessor<CommentProps>) {
onSubmit={view().editor!.onSubmit}
cancelLabel={view().editor!.cancelLabel}
submitLabel={view().editor!.submitLabel}
mention={view().editor!.mention}
/>
</div>
</Show>
@@ -91,7 +87,6 @@ function lineCommentDraftElementV2(view: Accessor<DraftProps>) {
onSubmit={view().onSubmit}
cancelLabel={view().cancelLabel}
submitLabel={view().submitLabel}
mention={view().mention}
/>
</div>
)
@@ -147,7 +142,6 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
},
cancelLabel: i18n.t("ui.lineComment.cancel"),
submitLabel: props.editSubmitLabel,
mention: props.mention,
}
: undefined
},
@@ -171,7 +165,6 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
},
cancelLabel: i18n.t("ui.lineComment.cancel"),
submitLabel: i18n.t("ui.lineComment.submit"),
mention: props.mention,
}),
})
@@ -209,7 +202,6 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
}
return {
note,
annotations,
renderAnnotation,
renderGutterUtility,
@@ -76,11 +76,6 @@
color: var(--text-strong, var(--v2-text-text-base));
}
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-title"]:not(:has([data-component="select-v2-root"])) {
margin-left: 8px;
}
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-title"]
[data-component="select-v2-root"] {
@@ -102,7 +97,7 @@
width: 100%;
max-width: 100%;
min-width: 0;
margin-top: 3px;
margin-top: 2px;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] {
@@ -26,6 +26,7 @@ export type SessionReviewV2Props = {
empty?: JSX.Element
sidebarOpen?: boolean
sidebar?: JSX.Element
sidebarToggle?: JSX.Element
activeFile?: string
files: string[]
onSelectFile: (file: string) => void
@@ -198,6 +199,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
const toolbarStart = () => (
<>
{props.sidebarToggle}
<Show when={showCollapsedMeta()}>
<div data-slot="session-review-v2-toolbar-collapsed-meta">
<Show when={title()}>
@@ -317,7 +319,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
)
}
export function SessionReviewV2SidebarToggle(props: { opened: boolean; disabled?: boolean; onToggle: () => void }) {
export function SessionReviewV2SidebarToggle(props: { opened: boolean; onToggle: () => void }) {
const i18n = useI18n()
return (
@@ -328,7 +330,6 @@ export function SessionReviewV2SidebarToggle(props: { opened: boolean; disabled?
class="session-review-v2-sidebar-toggle"
aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")}
aria-expanded={props.opened}
disabled={props.disabled}
onClick={props.onToggle}
icon={<Icon name="filetree" />}
/>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.17.19",
"version": "1.17.18",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-app",
"version": "1.17.19",
"version": "1.17.18",
"private": true,
"type": "module",
"license": "MIT",
@@ -39,7 +39,6 @@ import {
uniqueComparisonPairs,
type ComparisonModelRef,
} from "../compare-cards"
import { createBreadcrumbMenuRoot } from "../breadcrumb-menu"
import {
applyThemePreference,
Footer,
@@ -285,10 +284,9 @@ function ModelHero(props: {
const labModels = () =>
props.catalogData?.labs.find((lab) => lab.id === providerSlug(labId()))?.models ??
(props.catalog ? [props.catalog] : [])
const menuRoot = createBreadcrumbMenuRoot()
return (
<section id="overview" data-section="model-hero">
<nav ref={menuRoot} data-component="model-hero-breadcrumb" aria-label="Data breadcrumb">
<nav data-component="model-hero-breadcrumb" aria-label="Data breadcrumb">
<a data-slot="model-hero-crumb" href={language.route(import.meta.env.BASE_URL)}>
Data
</a>
@@ -296,14 +294,14 @@ function ModelHero(props: {
<Show
when={labs().length > 0}
fallback={
<span data-slot="model-hero-crumb" data-menu="true">
<span data-slot="model-hero-crumb" data-current="true">
<span>{props.labName}</span>
<ChevronDownIcon />
</span>
}
>
<details data-component="model-hero-menu">
<summary data-slot="model-hero-crumb" data-menu="true">
<summary data-slot="model-hero-crumb" data-current="true">
<span>{props.labName}</span>
<ChevronDownIcon />
</summary>
@@ -326,14 +324,14 @@ function ModelHero(props: {
<Show
when={labModels().length > 0}
fallback={
<span data-slot="model-hero-crumb" data-menu="true" data-current="true" aria-current="page">
<span data-slot="model-hero-crumb" data-current="true" aria-current="page">
<span>{modelName()}</span>
<ChevronDownIcon />
</span>
}
>
<details data-component="model-hero-menu">
<summary data-slot="model-hero-crumb" data-menu="true" data-current="true" aria-current="page">
<summary data-slot="model-hero-crumb" data-current="true" aria-current="page">
<span>{modelName()}</span>
<ChevronDownIcon />
</summary>
@@ -29,7 +29,6 @@ import { SectionHeading } from "../section-heading"
import { runStatsEffect } from "../../stats-runtime"
import { setStatsPageCacheHeaders } from "../stats-cache"
import { ComparisonCardsSection, modelRefFromCatalog, uniqueComparisonPairs } from "../compare-cards"
import { createBreadcrumbMenuRoot } from "../breadcrumb-menu"
import {
applyThemePreference,
Footer,
@@ -206,9 +205,8 @@ function LabHero(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[] }) {
function LabHeroBreadcrumb(props: { label: string; labs?: ModelCatalogLab[] }) {
const language = useLanguage()
const labs = () => props.labs ?? []
const menuRoot = createBreadcrumbMenuRoot()
return (
<nav ref={menuRoot} data-component="lab-hero-breadcrumb" aria-label="Data breadcrumb">
<nav data-component="lab-hero-breadcrumb" aria-label="Data breadcrumb">
<a data-slot="lab-hero-crumb" href={language.route(import.meta.env.BASE_URL)}>
Data
</a>
@@ -1,52 +0,0 @@
import { onCleanup, onMount } from "solid-js"
export function createBreadcrumbMenuRoot() {
let root: HTMLElement | undefined
onMount(() => {
const closeMenus = (except?: HTMLDetailsElement) => {
root?.querySelectorAll<HTMLDetailsElement>("details[open]").forEach((menu) => {
if (menu !== except) menu.open = false
})
}
const onPointerDown = (event: PointerEvent) => {
const path = event.composedPath()
root?.querySelectorAll<HTMLDetailsElement>("details[open]").forEach((menu) => {
if (!path.includes(menu)) menu.open = false
})
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return
const menu = root?.querySelector<HTMLDetailsElement>("details[open]")
if (!menu) return
event.preventDefault()
menu.open = false
menu.querySelector<HTMLElement>("summary")?.focus()
}
const onToggle = (event: Event) => {
if (!(event.target instanceof HTMLDetailsElement) || !event.target.open) return
closeMenus(event.target)
}
const onFocusOut = () => {
queueMicrotask(() => {
if (root?.contains(document.activeElement)) return
closeMenus()
})
}
document.addEventListener("pointerdown", onPointerDown)
document.addEventListener("keydown", onKeyDown)
root?.addEventListener("toggle", onToggle, true)
root?.addEventListener("focusout", onFocusOut)
onCleanup(() => {
document.removeEventListener("pointerdown", onPointerDown)
document.removeEventListener("keydown", onKeyDown)
root?.removeEventListener("toggle", onToggle, true)
root?.removeEventListener("focusout", onFocusOut)
})
})
return (element: HTMLElement) => {
root = element
}
}
+2 -18
View File
@@ -3113,16 +3113,8 @@
white-space: nowrap;
}
[data-page="stats"] a[data-slot="model-hero-crumb"] {
color: var(--stats-hero-muted);
}
[data-page="stats"] [data-slot="model-hero-crumb"][data-menu="true"] {
padding-right: 4px;
}
[data-page="stats"] [data-slot="model-hero-crumb"][data-current="true"] {
color: var(--stats-text);
padding-right: 4px;
}
[data-page="stats"] [data-component="model-hero-menu"] {
@@ -3159,7 +3151,7 @@
flex: 0 0 auto;
width: 16px;
height: 16px;
color: currentColor;
color: var(--stats-faint);
}
[data-page="stats"] [data-component="model-hero-menu"][open] [data-slot="model-hero-crumb"] svg {
@@ -5541,14 +5533,6 @@
white-space: nowrap;
}
[data-page="stats"] a[data-slot="compare-home-crumb"] {
color: var(--stats-hero-muted);
}
[data-page="stats"] [data-slot="compare-home-crumb"][data-current="true"] {
color: var(--stats-text);
}
[data-page="stats"] a[data-slot="compare-home-crumb"]:hover,
[data-page="stats"] a[data-slot="compare-home-crumb"]:focus-visible {
color: var(--stats-text);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-core",
"version": "1.17.19",
"version": "1.17.18",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-server",
"version": "1.17.19",
"version": "1.17.18",
"private": true,
"type": "module",
"license": "MIT",
@@ -60,10 +60,6 @@ export function isPromptEqual(a: Prompt, b: Prompt) {
return a.every((part, i) => JSON.stringify(part) === JSON.stringify(b[i]))
}
export function isCommentItem(item: ContextItem) {
return !!item.comment?.trim()
}
export function createPromptState() {
const [store, setStore] = createStore({
prompt: clonePrompt(DEFAULT_PROMPT),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/tui",
"version": "1.17.19",
"version": "1.17.18",
"private": true,
"type": "module",
"license": "MIT",
+1 -11
View File
@@ -56,7 +56,6 @@ import { useTuiConfig } from "../../config"
import { usePromptWorkspace } from "./workspace"
import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import { useLocation } from "../../context/location"
registerOpencodeSpinner()
@@ -149,7 +148,6 @@ export function Prompt(props: PromptProps) {
const local = useLocal()
const args = useArgs()
const paths = useTuiPaths()
const location = useLocation()
const terminalEnvironment = useTuiTerminalEnvironment()
const clipboard = useClipboard()
const sdk = useSDK()
@@ -1639,15 +1637,7 @@ export function Prompt(props: PromptProps) {
<text fg={theme.accent}>(new working copy)</text>
</box>
</Match>
<Match when={true}>
{props.hint ?? (
<Show when={props.sessionID}>
<box marginLeft={1}>
<text fg={theme.textMuted}>{location()?.directory ?? paths.cwd}</text>
</box>
</Show>
)}
</Match>
<Match when={true}>{props.hint ?? <text />}</Match>
</Switch>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.17.19",
"version": "1.17.18",
"type": "module",
"license": "MIT",
"repository": {

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