mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 16:56:33 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3802736137 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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,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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ export {
|
||||
createPromptSession,
|
||||
createPromptState,
|
||||
DEFAULT_PROMPT,
|
||||
isCommentItem,
|
||||
isPromptEqual,
|
||||
} from "./prompt-state"
|
||||
export type {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.19",
|
||||
"version": "1.17.18",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.17.19",
|
||||
"version": "1.17.18",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -128,8 +128,6 @@ export const fffLayer = Layer.effect(
|
||||
Fff.create({
|
||||
basePath: location.directory,
|
||||
aiMode: true,
|
||||
disableMmapCache: true,
|
||||
disableContentIndexing: true,
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
}).pipe(
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.17.19",
|
||||
"version": "1.17.18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+112722
-168115
File diff suppressed because it is too large
Load Diff
@@ -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,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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.17.19",
|
||||
"version": "1.17.18",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,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,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,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",
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user