Compare commits

..

4 Commits

Author SHA1 Message Date
Aiden Cline 2a3340936d Merge branch 'llm-error-types' into llm-remove-provider-error 2026-07-13 15:15:54 -05:00
Aiden Cline f981861b67 Merge remote-tracking branch 'origin/v2' into llm-error-types 2026-07-13 15:15:43 -05:00
Aiden Cline 5d87c7ad1f refactor(llm): remove the provider-error stream event
Delete the provider-error LLMEvent so streams carry output only and
every provider-reported failure exits through the typed error channel.
Anthropic SSE error events, OpenAI Responses response.failed/error
events, and Bedrock exception frames now fail the stream with an
LLMError from the shared classifier (carrying the provider code, e.g.
rate_limit_exceeded -> LLM.RateLimit).

Core follows: the publisher drops its provider-error case, the runner
drops held-back overflow events (overflow recovery keys off a thrown
LLM.ContextOverflow), compaction and title stop scanning events for
failures, and isContextOverflowFailure is deleted with its last
consumer. V1 packages/opencode gets minimal compile fixes only (dead
switch case, one test stream).
2026-07-13 12:22:30 -05:00
Aiden Cline fce506b3f9 refactor(llm): replace LLMError reasons with flat tagged union
Replace the LLMError { module, method, reason } wrapper with a flat
tagged union (LLM.BadRequest, LLM.Authentication, LLM.PermissionDenied,
LLM.NotFound, LLM.RateLimit, LLM.QuotaExceeded, LLM.ContentPolicy,
LLM.ContextOverflow, LLM.ServerError, LLM.APIError, LLM.ConnectionError,
LLM.TimeoutError, LLM.MalformedResponse, LLM.NoRoute) plus an isLLMError
guard. Add one shared classifyApiFailure classifier used by the HTTP
executor and the AI SDK adapter so both surfaces classify identically,
preserving status, headers, body, and retry-after.

Core policy moves onto tags: retry RateLimit | ServerError |
ConnectionError | TimeoutError; toSessionError adds
provider.context-overflow, provider.timeout, and provider.not-found.

The provider-error stream event and the runner's held-back overflow
handling are unchanged here; isContextOverflowFailure now bridges old
events and new tags until the event is removed.
2026-07-13 12:21:21 -05:00
589 changed files with 11616 additions and 22339 deletions
+1 -3
View File
@@ -1,7 +1,5 @@
---
"@opencode-ai/client": patch
"@opencode-ai/protocol": patch
"@opencode-ai/cli": patch
---
Expose background-service lifecycle status, preserve one process-held owner through startup and failure, reconnect TUIs without activating replacement, and stop exact service instances gracefully.
Reuse a same-version background service when a repeated health probe succeeds instead of replacing an endpoint another client may already be using.
+1 -1
View File
@@ -18,4 +18,4 @@ simonklee
Slickstef11
usrnk1
vimtor
StarpTech
starptech
-8
View File
@@ -70,14 +70,6 @@ jobs:
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Verify compiled service lifecycle
if: always()
timeout-minutes: 10
working-directory: packages/cli
run: |
bun run script/build.ts --single --skip-install
bun run script/service-smoke.ts
- name: Check generated client
if: runner.os == 'Linux'
working-directory: packages/client
+1 -1
View File
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
const TEAM = {
tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"],
inference: ["fwang", "MrMushrooooom", "starptech"],
windows: ["Hona"],
} as const
+52 -85
View File
@@ -32,7 +32,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -108,6 +108,7 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
@@ -126,7 +127,6 @@
"uqr": "0.1.3",
},
"devDependencies": {
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
@@ -158,7 +158,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -172,7 +172,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -208,7 +208,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -235,7 +235,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -257,7 +257,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -281,7 +281,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -301,7 +301,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.17.20",
"version": "1.17.18",
"bin": {
"opencode": "./bin/opencode",
},
@@ -309,7 +309,7 @@
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.88",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
@@ -318,7 +318,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",
@@ -355,7 +355,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.1",
"gitlab-ai-provider": "6.10.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -397,7 +397,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -457,7 +457,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -471,7 +471,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"effect": "catalog:",
},
@@ -483,7 +483,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -515,7 +515,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -531,7 +531,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.83",
},
@@ -563,7 +563,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -582,7 +582,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.20",
"version": "1.17.18",
"bin": {
"opencode": "./bin/opencode",
},
@@ -593,7 +593,7 @@
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.88",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.60",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
@@ -602,7 +602,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",
@@ -657,7 +657,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.1",
"gitlab-ai-provider": "6.10.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -715,7 +715,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/client": "workspace:*",
@@ -802,7 +802,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -817,7 +817,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -834,7 +834,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -880,7 +880,7 @@
"name": "@opencode-ai/simulation",
"version": "1.17.13",
"dependencies": {
"@fontsource/commit-mono": "5.2.5",
"@fontsource/adwaita-mono": "5.2.1",
"@napi-rs/canvas": "1.0.2",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/llm": "workspace:*",
@@ -895,7 +895,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -908,10 +908,9 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -942,7 +941,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -961,7 +960,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -1002,11 +1001,12 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opentui/core": "catalog:",
@@ -1032,7 +1032,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1083,7 +1083,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.20",
"version": "1.17.18",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1154,7 +1154,6 @@
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
@@ -1162,8 +1161,10 @@
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
},
"overrides": {
@@ -1199,7 +1200,7 @@
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@tailwindcss/vite": "4.1.11",
"@tanstack/solid-virtual": "3.13.32",
"@tanstack/solid-virtual": "3.13.28",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.13",
@@ -1219,7 +1220,7 @@
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"luxon": "3.6.1",
"marked": "17.0.6",
"marked": "17.0.1",
"marked-shiki": "1.2.1",
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
@@ -1261,7 +1262,7 @@
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="],
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="],
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="],
@@ -1271,7 +1272,7 @@
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="],
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="],
@@ -1765,7 +1766,7 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@fontsource/commit-mono": ["@fontsource/commit-mono@5.2.5", "", {}, "sha512-htX8yQWtiPt5L1Hzh4sirvfUJT2+KYiquDB/Q2sY2tWQYplpBUOD5zHnIM3k36Hnm4V+JIIqA/wmwupSQ68WjA=="],
"@fontsource/adwaita-mono": ["@fontsource/adwaita-mono@5.2.1", "", {}, "sha512-6+Q1UIvklJ9REijs6kv7YlRNt6yktRj0iW8H69YIugdD9P2h3eIX1AB8/9ICMfpVyVeywlsrCXg82y/LfRrjyg=="],
"@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.5", "", {}, "sha512-G09N3GfuT9qj3Ax2FDZvKqZttzM3v+cco2l8uXamhKyXLdmlaUDH5o88/C3vtTHj2oT7yRKsvxz9F+BXbWKMYA=="],
@@ -3123,7 +3124,7 @@
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="],
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="],
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.13", "", { "dependencies": { "@tanstack/router-core": "1.171.14", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.16", "seroval": "^1.5.4" } }, "sha512-o37M3msIK5ec87kPrIYJWXb1XPnjIe5/jrkGLXiXpFuVL99z7mhoBCzftKtVPtzqI8EElnRE/VGFYT9BHNnWcw=="],
@@ -3137,7 +3138,7 @@
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="],
@@ -4345,7 +4346,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.11.1", "", { "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-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="],
"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=="],
@@ -4855,7 +4856,7 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
@@ -6341,11 +6342,7 @@
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/azure/@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=="],
"@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/azure/@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=="],
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -6357,9 +6354,9 @@
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/deepseek/@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=="],
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
@@ -6849,7 +6846,7 @@
"@opencode-ai/console-mail/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
"@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=="],
@@ -6889,8 +6886,6 @@
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
@@ -7237,10 +7232,6 @@
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
"ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
"ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
"ai-gateway-provider/@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=="],
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
@@ -7545,7 +7536,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=="],
@@ -8237,10 +8228,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=="],
@@ -8485,14 +8472,6 @@
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"ai-gateway-provider/@ai-sdk/xai/@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=="],
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -8805,10 +8784,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=="],
@@ -9143,8 +9118,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=="],
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
@@ -9331,10 +9304,6 @@
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
@@ -9435,8 +9404,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=="],
-623
View File
@@ -1,623 +0,0 @@
# Service Lifecycle: Election, Restart, and Reconnect
Status: in progress
Incident: [#36688](https://github.com/anomalyco/opencode/issues/36688)
## Summary
The managed V2 service keeps its current update policy: the background updater
may install a new package, but only a freshly launched TUI activates that update
after finding an older running service. Existing TUIs never replace a service;
they only reconnect.
The restart path changes in three places:
1. A process-held OS lock, not the HTTP port or registration file, elects
exactly one server owner for its lifetime.
2. The elected process binds and registers a minimal lifecycle surface before
it initializes the application, so clients can distinguish a slow winner
from an absent server.
3. TUIs rediscover and reconnect indefinitely. Transport loss is never a
terminal error by itself.
Several clients may spawn small contenders during a restart. This is safe and
intentional: one contender acquires the lock and initializes, while every loser
exits before expensive server boot. The design does not require clients to
agree on a single initiator.
This proposal does not introduce a supervisor process, warm candidate server,
protocol negotiation, idle background restart, or general execution-recovery
framework.
## Implementation Status
| Area | State |
| ------------------------- | --------------------------------------------------------------------- |
| Lifetime ownership | Implemented on this branch with a scoped OS lock |
| Contender behavior | Implemented; losers exit before the server module is imported |
| Registration repair | Implemented; the owner reasserts deleted or corrupt discovery |
| Channel isolation | Implemented with no-clobber migration for legacy preview discovery |
| Client startup waiting | Implemented; slow winners are not killed and waiting is indefinite |
| Lifecycle shell | Implemented; the owner binds and registers before application boot |
| Failed-state latching | Implemented; deterministic boot failure stays bound and actionable |
| Recovery diagnostics | Implemented; the TUI shows status instead of transport internals |
| Cross-platform validation | macOS runtime verified; Linux and Windows run in the unit-test matrix |
## Context
The V2 CLI runs a shared managed service that owns Sessions, location graphs,
plugins, permissions, and tool execution. The service updater can replace the
installed package while the current process continues running the old image.
A later TUI launch then detects the version mismatch and replaces the service.
Incident #36688 showed four failures in that replacement path:
- Multiple TUIs spawned heavyweight server contenders.
- A winner remained unobservable while it cold-booted, so another wave treated
it as absent and displaced it.
- A fresh TUI exhausted its reconnect budget and crashed with an unhandled
transport defect.
- A losing contender remained alive and consumed about 1 GB of RSS.
The `origin/v2` baseline serializes service startup with `EffectFlock`. A
contender acquires a three-second heartbeat lease, checks whether another
service became discoverable, and only the winner crosses the application-boot
boundary. This already prevents simultaneous heavy boots and makes startup
losers exit.
The lease is released immediately after registration, however, so it is not
lifetime ownership. Registration then reverts to last-writer-wins authority: a
deleted or corrupt registration can admit a second boot, a displaced server
terminates itself through its 10-second registration self-check, and a stalled
lease holder can be displaced after the three-second service staleness timeout.
`Flock` and `EffectFlock` live in `packages/core/src/util` and are also used for
config writes, MCP auth, npm installs, and repository caching. Despite the
name, the primitive is an atomic-mkdir lease with heartbeat and staleness
takeover, not an OS-held lock. It remains appropriate for bounded critical
sections, including today's startup fence, but is not lifetime service
ownership.
The current implementation also mixes three different concepts:
- **Ownership:** which process is allowed to be the managed server.
- **Discovery:** where clients can reach that process.
- **Lifecycle:** whether that process is starting, ready, stopping, or failed.
This design gives each concept one authority.
```definitions
[
{
"term": "Owner",
"definition": "The one process holding the process-held OS service lock."
},
{
"term": "Contender",
"definition": "A small serve process attempting to acquire the service lock. It must not initialize the application before winning."
},
{
"term": "Registration",
"definition": "An atomic discovery record containing the elected owner's identity and endpoint. Registration never grants ownership."
},
{
"term": "Lifecycle shell",
"definition": "The minimal HTTP surface bound by the elected process before application initialization. It serves health and retryable startup responses."
},
{
"term": "Application",
"definition": "The full server routes and global or location-scoped modules used for normal OpenCode work."
}
]
```
## Goals
- At most one process initializes and serves the managed application.
- Losing contenders exit before database, route, plugin, MCP, or location boot.
- A slow winner becomes observable before expensive initialization.
- Existing and freshly launched TUIs survive retryable service unavailability.
- Reconnect follows service state instead of displaying retry counts or raw
transport failures.
- Version-mismatch replacement remains triggered by a fresh TUI launch.
- A stale or malformed registration cannot create a second owner.
- An unresponsive owner is never killed automatically by an arbitrary TUI.
- Every spawned contender has a bounded path to ownership or exit.
## Non-goals
- Restarting automatically when a background update finds an idle window.
- Running old and candidate application servers concurrently.
- Adding a permanent steward, proxy, or supervisor process.
- Zero-downtime worker handoff or automatic rollback.
- Application protocol negotiation or automatic TUI self-restart.
- General hard-crash recovery for active Sessions.
- Defining recovery semantics for provider attempts, tools, shells, sub-agents,
permissions, questions, or background jobs.
- Automatically killing a frozen owner.
- Bounding concurrent location cold boots after clients reconnect.
- Multi-machine or clustered service placement.
## Invariants
1. **The service lock is ownership.** Exactly one process may hold the OS lock
for one installation channel and service profile.
2. **Ownership precedes boot.** A contender performs no expensive application
initialization before it acquires the lock.
3. **Ownership lasts for the process lifetime.** The owner holds an open lock
handle until the managed server exits. The OS releases it on process death
without a cleanup callback.
4. **The port is transport, not election.** The owner may select a dynamic port
after acquiring the lock.
5. **Registration is discovery, not election.** Deleting, corrupting, or
replacing registration does not invalidate a live owner's lock.
6. **Only a fresh launch enforces package version.** Existing TUIs reconnect to
the current owner without initiating version replacement.
7. **Transport loss is retryable.** It never terminates a TUI without a separate
diagnosed, non-retryable cause.
8. **Clients do not kill an unresponsive owner automatically.** Destructive
recovery requires the explicit `service restart` command.
9. **Lifecycle does not promise execution semantics.** Graceful replacement
invokes Session suspension and resumption hooks, but tool-level continuity
belongs to a separate design.
## System Model
```mermaid
flowchart LR
TUI[Fresh or existing TUI]
REG[Registration file]
LOCK[Process-held OS service lock]
SHELL[Lifecycle shell]
APP[OpenCode application]
TUI -->|discover| REG
TUI -->|observe| SHELL
TUI -->|normal requests| APP
SHELL --> APP
LOCK -->|authorizes one owner| SHELL
```
The lifecycle shell and application run in the same process. The distinction is
initialization order and responsibility, not process topology.
## Service Status
The server reports one small status value:
```typescript
type ServiceStatus =
| {
type: "starting"
}
| {
type: "ready"
}
| {
type: "stopping"
targetVersion?: string
}
| {
type: "failed"
message: string
action: string
}
```
The client adds only the discovery states needed by callers:
```typescript
type Status = { type: "missing" } | { type: "unreachable" } | { type: "unresponsive" } | ServiceStatus
```
The health response retains the existing fields for old clients and adds the
status discriminant:
```typescript
type ServiceHealth = {
healthy: true
version: string
pid: number
instanceID: string
status: ServiceStatus
}
```
`healthy: true` means the registered lifecycle shell is responding and its
identity matches registration. New clients use `status.type === "ready"` as
the application-readiness signal.
During `starting` or `stopping`, application requests are not held in memory.
They receive an immediate retryable response:
```http
HTTP/1.1 503 Service Unavailable
Retry-After: 1
Content-Type: application/json
```
`stopping` uses `service_stopping`. A failed application boot uses
`service_failed` and includes a safe diagnostic message.
A failed owner remains bound and keeps holding the service lock. Exiting on
failure would let every waiting client's `ensureRunning` loop elect a new
contender that repeats the same heavy failing boot, so staying bound turns a
deterministic boot failure into one observable `failed` state instead of a
client-driven respawn loop. Recovery still works: a fresh launch observes the
failed instance through the stop path, and explicit `service restart` replaces
it.
## Registration Contract
Registration contains only discovery identity:
```typescript
type ServiceRegistration = {
schema: 1
instanceID: string
version: string
url: string
pid: number
}
```
Authentication continues to use the existing private service credential
storage. The registration schema does not change that policy.
The owner writes registration only after the lifecycle shell has bound:
1. Bind the lifecycle shell.
2. Write a temporary registration file with mode `0600`.
3. Atomically rename it over the old registration.
4. Serve lifecycle health as `starting`.
On shutdown, the owner removes registration only if the current file still has
its `instanceID`. An old finalizer can never remove a successor's registration.
While running, the owner periodically asserts its registration. Because the
lock guarantees exactly one live owner, any registration that does not name the
owner is stale or corrupt, and the owner rewrites it. A deleted or clobbered
registration therefore heals within one assertion interval instead of leaving
clients waiting on absent discovery. This inverts today's self-check loop,
which terminates the displaced process instead of repairing discovery.
Legacy registration shapes are decoded by a compatibility adapter. The new
domain type does not make fields optional to represent old formats.
## Election
This design promotes today's startup fence into lifetime ownership.
Last-writer-wins registration is replaced by a process-held OS lock that is
acquired before any expensive boot work and held for the entire service
lifetime.
A heartbeat-and-staleness lease, including the existing `Flock` utility, is not
sufficient for service ownership: the service configures a three-second stale
timeout, after which its lock can be broken and recreated. An event-loop stall,
a suspended machine, or a debugger pause can therefore make a live owner appear
stale and allow a contender to displace it. Service ownership requires a
process-held OS lock: `flock` on Unix and an exclusively bound named pipe on
Windows. It cannot be broken because a heartbeat exceeded a timeout. Process
death releases the lock through the OS.
Neither Bun nor Node exposes `flock` directly, the existing `Flock` utility is
an mkdir-plus-heartbeat lease rather than an OS-held lock, and the common
lockfile packages are staleness-based leases as well. The platform layer uses
`bun:ffi` to call `flock` on POSIX and Node's named-pipe server support on
Windows, where Bun FFI is not available on every shipped architecture. It lives
alongside the existing utility in `packages/core/src/util`. This primitive is
the foundation of the design, so the delivery sequence spikes it first.
```mermaid
flowchart TD
A[Contender process starts]
B{Acquire service lock?}
C[Exit successfully]
D[Bind lifecycle shell]
E[Write registration]
F[Report starting]
G[Initialize application]
H[Report ready]
I[Serve until shutdown]
A --> B
B -->|No| C
B -->|Yes| D
D --> E --> F --> G
G -->|Success| H --> I
G -->|Failure| J[Report failed and stay bound]
```
Lock acquisition by a contender is nonblocking or tightly bounded. A loser
must exit before constructing application routes or importing startup-heavy
modules.
Several clients may spawn contenders concurrently. The design guarantees one
heavy winner, not one process spawn. If the winner crashes during startup, the
OS releases the lock and a later client retry starts another election.
The lock is scoped by installation channel and service profile. Local, preview,
and stable installations cannot displace one another.
## Update Activation
Background update behavior remains unchanged:
1. The running service checks for an update.
2. The updater installs the package in the background.
3. The running process continues using its existing process image.
4. No idle check or automatic restart occurs.
A fresh TUI launch activates the installed update:
1. Read registration and authenticate the responding service.
2. If its package version matches the fresh client, attach normally.
3. If the version differs, request graceful stop of that exact registered
instance using the existing authenticated stop path.
4. Re-check instance identity before every signal or escalation in that path.
5. Wait for the old process to exit and release the service lock.
6. Call `ensureRunning` until a compatible service becomes ready.
Concurrent fresh launchers may all observe the same old instance. Stopping that
exact instance must be idempotent. Once registration names a different instance,
a stale launcher stops signaling and returns to discovery.
No durable restart-transition record is introduced. The initiating fresh TUI
already knows the source and target versions and can display its update
preflight. Existing TUIs may display `Updating...` if they observed `stopping`;
otherwise `Waiting for background service...` is the honest fallback.
## Fresh Launch Versus Reconnect
Fresh launch and reconnect deliberately have different version policies:
```typescript
type ManagedConnection =
| {
type: "launch"
requiredVersion: string
}
| {
type: "reconnect"
}
```
- `launch` requires the installed package version and may activate replacement.
- `reconnect` accepts the current owner and never activates replacement.
This preserves today's permissive reconnect behavior. Explicit application
protocol negotiation and automatic TUI re-exec remain follow-ups.
## Client Reconnect
Fresh and existing TUIs use the same status loop after startup:
1. Read registration on every attempt. Do not retry a stale URL indefinitely.
2. If registration is absent, call `ensureRunning` and continue waiting.
3. If registration is unreachable, call `ensureRunning`. A live owner prevents
contenders from acquiring the lock; a dead owner does not.
4. If status is `starting` or `stopping`, wait.
5. If status is `failed`, show its actionable message.
6. If status is `ready`, rebuild HTTP and event-stream clients for the new
endpoint and perform authoritative state reconciliation.
Retry cadence is internal policy. Retry counts are telemetry, not user-facing
state. The TUI waits until the service is ready or the user exits.
Transport failures are handled at the TUI run boundary. A raw client transport
error or Effect defect must not escape to the terminal. Hard exit is reserved
for diagnosed causes such as invalid local configuration, failed authentication,
or a foreign process occupying an explicitly configured port.
The UI derives text from status:
| Status | User-facing state |
| ------------------------ | ----------------------------------- |
| No registration | `Starting background service...` |
| Registration unreachable | `Waiting for background service...` |
| `starting` | `Starting OpenCode vX...` |
| `stopping` | `Updating to vX...` |
| `failed` | Actionable failure message |
| `ready` | Normal TUI |
## Graceful Session Continuity
Version-mismatch replacement uses the existing graceful Session suspension and
resumption hooks:
1. The old server snapshots active Session IDs during graceful teardown.
2. The successor schedules those Sessions for continuation.
3. The runner reloads durable Session history before continuing.
This lifecycle design does not define what an interrupted physical provider
attempt or tool invocation means. It does not promise that external side effects
did not occur, replay the exact interrupted tool, preserve an in-memory form, or
recover process-local background work.
Those concerns require a separate execution-continuity design covering tools,
shells, sub-agents, permissions, questions, provider attempts, and hard-crash
recovery.
## Unresponsive Owner
An unreachable registration does not prove that the owner is dead. A contender
attempts the service lock:
- If the lock is free, the contender starts a replacement.
- If the lock is held, the contender exits and the client keeps waiting.
After a bounded diagnostic threshold, the client may show:
```text
The background service owns the service lock but is not responding.
Run `opencode service restart` to recover it.
```
Only explicit `service restart` may perform destructive recovery. It verifies
the complete registration and process instance before signaling, waits for
graceful exit, re-checks identity before escalation, and refuses to kill a
process it cannot positively identify.
Automatic frozen-owner recovery is deferred.
## Failure Walkthroughs
### Update with open TUIs
1. The old service installs vNext but keeps running.
2. A fresh vNext TUI finds the healthy vOld service and requests graceful stop.
3. The old service reports `stopping`, suspends active Sessions, and exits.
4. Open TUIs enter their indefinite status loops.
5. One or more clients spawn contenders.
6. One contender acquires the service lock. Losers exit before heavy boot.
7. The winner binds and registers the lifecycle shell as `starting`.
8. Clients stop spawning and wait on the observable winner.
9. The winner initializes the application and reports `ready`.
10. TUIs rebuild clients, reconcile state, and resume.
### Server crashes while ready
1. The endpoint becomes unreachable and registration may remain stale.
2. Clients call `ensureRunning`.
3. Process death has released the service lock.
4. One contender wins, replaces registration, and starts normally.
5. Detailed active-execution recovery is outside this design.
### Winner crashes during startup
1. Clients observed `starting` and remain alive.
2. Process death releases the service lock.
3. A later reconnect attempt starts another election.
4. One new contender wins; all other contenders exit.
### Registration is deleted while the owner is healthy
1. Clients may call `ensureRunning` because discovery is absent.
2. Every contender fails to acquire the owner's lock and exits.
3. No second application initializes.
4. The owner's next registration assertion republishes discovery.
### Owner is alive but unresponsive
1. Health fails, but the process still holds the service lock.
2. Contenders fail lock acquisition and exit.
3. Clients wait and eventually show explicit recovery guidance.
4. No TUI kills the owner automatically.
## TDD Verification
Implementation should proceed test-first with real subprocesses and real locks.
Mocks cannot establish process death, lock release, loser cleanup, or port
behavior.
### Election tests
| Scenario | Required result |
| ----------------------------------------------------- | ------------------------------------------------------- |
| Ten contenders start simultaneously | Exactly one crosses the application-boot boundary |
| Winner pauses after lock acquisition | No loser initializes or remains alive |
| Winner event loop pauses beyond the old stale timeout | Ownership is not displaced |
| Winner crashes before bind | Lock releases; a later attempt wins |
| Winner crashes after bind but before registration | Lock releases; a later attempt replaces stale discovery |
| Registration is deleted while owner runs | No second owner initializes |
| Registration is malformed | Lock still prevents a second owner |
| Registration names a dead PID | New contender can acquire the released lock |
| Two installation channels start | Each elects an independent owner |
| Explicit configured port is foreign-owned | Fail diagnostically; do not kill the foreign process |
The fixture records a marker immediately before application initialization. The
tests assert that only one process writes that marker and that every loser exits
within a bounded interval. The harness should also assert that a loser's peak
RSS stays an order of magnitude below an application boot, since import weight
was the observed incident cost.
### Lifecycle tests
| Scenario | Required result |
| ----------------------------------------------- | ---------------------------------------------------------------- |
| Winner owns lock but application boot is paused | Health reports `starting` |
| Application request arrives during startup | Immediate retryable `503` |
| Application becomes ready | Status changes once from `starting` to `ready` |
| Graceful replacement begins | Status reports `stopping` before disconnect |
| Application initialization fails | Actionable `failed` status; owner stays bound and holds the lock |
| Registration is deleted while owner runs | Owner republishes it within one assertion interval |
| Owner exits | Registration is removed only if it still names that owner |
### Update tests
| Scenario | Required result |
| -------------------------------------- | -------------------------------------------------------- |
| Background update installs vNext | Running vOld service does not restart |
| Fresh vNext launch finds vOld | Exact old instance stops; vNext eventually becomes ready |
| Two fresh vNext launches race | One heavy successor; both clients attach |
| Existing vOld TUI reconnects to vNext | It never requests replacement |
| Stale launcher observes a new instance | It does not signal the new instance |
### Reconnect tests
| Scenario | Required result |
| --------------------------------------------------- | -------------------------------------------------- |
| Endpoint disappears and changes port | TUI rediscovers and rebuilds clients |
| Service remains unavailable beyond old retry budget | TUI remains alive |
| Event stream reconnects | Client performs authoritative state reconciliation |
| Transport returns an unexpected defect | TUI formats it; no raw stack escapes |
| Owner remains unresponsive | TUI waits and shows explicit restart guidance |
## Delivery Sequence
1. **Spike the lock primitive.** Prove a nonblocking, process-held OS lock
under Bun on macOS, Linux, and Windows (`bun:ffi` to `flock` on POSIX and a
named pipe on Windows), including release on hard kill and behavior across
containers and network filesystems used in CI.
2. **Expand the subprocess test harness.** Begin from the baseline
two-contender test and cover ten contenders, lock release on crash, a paused
winner, deleted or corrupt registration, and bounded loser exit before
changing ownership.
3. **Contain client failure.** Make transport loss nonterminal, rediscover on
every cycle, and format unexpected failures at the TUI boundary.
4. **Promote the startup fence to process-held ownership.** Preserve the
existing pre-boot acquisition seam, replace its lease with the OS lock, hold
it until process exit, and invert the registration self-check from
self-termination to reassertion.
5. **Bind the lifecycle shell first.** Publish registration and `starting`,
return retryable `503` for application requests, then initialize the app.
The health contract change is public API: regenerate clients from
`packages/client` with `bun run generate`.
6. **Codify launch versus reconnect.** Fresh launch enforces installed version;
reconnect never activates replacement.
7. **Integrate graceful replacement.** Preserve current background-install and
fresh-launch activation behavior while invoking Session continuity hooks.
8. **Harden explicit recovery.** Verify exact process identity during explicit
`service restart`; never automatically kill an unresponsive owner.
9. **Run the full multi-process suite.** Include repeated restart cycles and
assert that no contender or child process remains afterward.
## Acceptance Criteria
- Ten concurrent restart observers produce one application initialization.
- No losing contender survives or builds a location graph.
- A 30-second application boot remains continuously observable as `starting`.
- A TUI remains alive through a service outage longer than the previous retry
budget.
- A service endpoint change does not require restarting an existing TUI.
- Background installation alone does not restart the service.
- A fresh mismatched TUI eventually attaches to the installed service version.
- Existing reconnecting TUIs never replace the current owner.
- Registration corruption cannot produce two owners.
- A deleted registration heals without restarting the owner or any client.
- An unresponsive owner is not killed without an explicit recovery command.
- Raw transport defects never escape to the terminal.
## Follow-ups
- Idle background update activation with an admission fence.
- Application protocol compatibility and automatic local TUI re-exec.
- Durable execution recovery for provider attempts and tools.
- Shell, sub-agent, permission, question, and background-job continuity.
- Automatic recovery for a positively identified frozen owner.
- Cold-boot concurrency limits and interaction-prioritized location loading.
- A steward or socket-handoff architecture if zero-downtime replacement becomes
a real requirement.
a real requirement.
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=",
"aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=",
"aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=",
"x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw="
"x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=",
"aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=",
"aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=",
"x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk="
}
}
+5 -4
View File
@@ -49,7 +49,7 @@
"@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.3",
"@tanstack/solid-virtual": "3.13.32",
"@tanstack/solid-virtual": "3.13.28",
"@shikijs/stream": "4.2.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
@@ -76,7 +76,7 @@
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"marked": "17.0.6",
"marked": "17.0.1",
"marked-shiki": "1.2.1",
"remend": "1.3.0",
"@playwright/test": "1.59.1",
@@ -161,9 +161,10 @@
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
}
}
@@ -136,9 +136,6 @@ export async function setupTimeline(
},
}),
)
if (settings.newLayoutDesigns === false) {
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
}
}, input.settings ?? {})
if (input.locale) {
await page.addInitScript((locale) => {
@@ -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 },
)
}
@@ -24,7 +24,6 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
@@ -1,270 +0,0 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport"
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
const directoryA = "C:/server-a"
const directoryB = "/home/server-b"
const sessionA = session("ses_server_a", directoryA, "Server A session")
const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
const sessionB = session("ses_server_b", directoryB, "Server B session")
test("session settings use the remote server context", async ({ page }) => {
const permissionRequests: string[] = []
await mockServers(page, permissionRequests)
await configureServers(page)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
const dialog = page.locator(".settings-v2-dialog")
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
await expect(autoAccept).toBeVisible()
await expect(input).toBeEnabled()
permissionRequests.length = 0
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(input).toBeChecked()
await expect
.poll(() =>
permissionRequests.some((request) => {
const url = new URL(request)
return url.origin === serverB && url.searchParams.get("directory") === directoryB
}),
)
.toBe(true)
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
await dialog.getByRole("tab", { name: "Models" }).click()
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
})
test("auto-accept responds for an unfocused server session", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
server: serverA,
retry: 20,
})
await mockServers(page, permissionRequests, permissionResponses)
await configureServers(page, [
{ type: "session", server: serverA, sessionId: sessionA.id },
{ type: "session", server: serverB, sessionId: sessionB.id },
])
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByText(sessionA.title).first()).toBeVisible()
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
.poll(() =>
permissionRequests.some((request) => {
const url = new URL(request)
return url.origin === serverA && url.searchParams.get("directory") === directoryA
}),
)
.toBe(true)
await page.keyboard.press("Escape")
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await transport.waitForConnection()
await transport.send({
directory: directoryA,
payload: {
id: "event-permission-background-a",
type: "permission.asked",
properties: {
id: "permission-background-a",
sessionID: sessionA.id,
permission: "bash",
patterns: ["git status"],
metadata: {},
always: [],
},
},
})
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: directoryA,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { response: "once" },
},
])
await transport.send({
directory: directoryA,
payload: {
id: "event-permission-background-a-child",
type: "permission.asked",
properties: {
id: "permission-background-a-child",
sessionID: childSessionA.id,
permission: "bash",
patterns: ["git diff"],
metadata: {},
always: [],
},
},
})
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: directoryA,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { response: "once" },
},
{
origin: serverA,
directory: directoryA,
sessionID: childSessionA.id,
permissionID: "permission-background-a-child",
body: { response: "once" },
},
])
})
type PermissionResponse = {
origin: string
directory?: string
sessionID: string
permissionID: string
body: unknown
}
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
await page.addInitScript(
({ serverB, tabs }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
},
{ serverB, tabs },
)
}
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
const remote = url.origin === serverB
const directory = remote ? directoryB : directoryA
const sessions = remote ? [sessionB] : [sessionA, childSessionA]
const requestDirectory = url.searchParams.get("directory")
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
if (route.request().method() === "POST" && response) {
permissionResponses.push({
origin: url.origin,
directory: requestDirectory ?? undefined,
sessionID: response[1]!,
permissionID: response[2]!,
body: route.request().postDataJSON(),
})
return json(route, true)
}
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session/status") return json(route, {})
if (url.pathname === "/session") return json(route, sessions)
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
if (current) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (url.pathname === "/permission") {
permissionRequests.push(url.toString())
return json(route, [])
}
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (url.pathname === "/project" || url.pathname === "/project/current") {
const project = {
id: remote ? sessionB.projectID : "project-server-a",
worktree: directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/project" ? [project] : project)
}
if (url.pathname === "/path")
return json(route, {
state: directory,
config: directory,
worktree: directory,
directory,
home: directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
return json(route, {})
})
}
function session(id: string, directory: string, title: string) {
return {
id,
slug: id,
projectID: `project-${id}`,
directory,
title,
version: "dev",
time: { created: 1, updated: 1 },
}
}
function provider(id: string) {
const name = id === "server-b" ? "Server B" : "Server A"
return {
all: [
{
id,
name: `${name} Provider`,
models: {
[id]: {
id,
name: `${name} Model`,
family: id,
release_date: "2026-01-01",
limit: { context: 200_000 },
},
},
},
],
connected: [id],
default: { providerID: id, modelID: id },
}
}
function json(route: Route, body: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify(body),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
@@ -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")
@@ -1,7 +1,6 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/RequestDocks"
@@ -101,67 +100,6 @@ test("shows a pending permission dock", async ({ page }) => {
expect(request.postDataJSON()).toEqual({ response: "once" })
})
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
const transport = await installSseTransport(page, {
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
retry: 20,
})
await mockServer(page, { questions: [] })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection()
await expectSessionTitle(page, title)
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
const draft = "keep the caret at the end"
await editor.fill(draft)
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft")
const cursor = draft.length - 4
await expect
.poll(() =>
editor.evaluate((element) => {
const selection = window.getSelection()
if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1
const range = selection.getRangeAt(0).cloneRange()
range.selectNodeContents(element)
range.setEnd(selection.anchorNode!, selection.anchorOffset)
return range.toString().length
}),
)
.toBe(cursor)
await transport.send({
directory,
payload: {
type: "question.asked",
properties: {
id: "question-caret",
sessionID,
questions: [
{
header: "Continue",
question: "Continue?",
options: [{ label: "Yes", description: "Continue the session" }],
},
],
tool: { messageID: "message-caret", callID: "call-caret" },
},
},
})
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question).toBeVisible()
await expect(editor).toHaveCount(0)
await transport.send({
directory,
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
})
await expect(question).toHaveCount(0)
await expect(editor).toBeVisible()
await page.keyboard.press("x")
await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`)
})
async function mockServer(
page: Page,
requests: {
@@ -17,14 +17,12 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const initialPageSize = 20
const historyPageSize = 200
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
const assistants = Array.from({ length: 14 }, (_, index) =>
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
parentID: userID,
created: 1700000001000 + index * 1_000,
completed: index < initialPageSize,
completed: index < 13,
}),
)
const messages = [userMessage(), ...assistants]
@@ -48,7 +46,7 @@ const scenarios = [
test.use({ viewport: { width: 646, height: 1385 } })
for (const scenario of scenarios) {
test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => {
test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => {
const requests: { before?: string; phase: "start" | "end" }[] = []
const pages: { before?: string; limit: number }[] = []
const roots: { sessionID: string; messageID: string }[] = []
@@ -103,51 +101,36 @@ for (const scenario of scenarios) {
}
},
})
await page.addInitScript(() => {
const visibleParts = () => {
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
const view = viewport?.getBoundingClientRect()
if (!viewport || !view) return []
return [...viewport.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
.filter((part) => {
const rect = part.getBoundingClientRect()
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
})
.flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : []))
}
const state = {
armed: false,
hidden: false,
visibleParts: [] as string[],
samples: 0,
stop: false,
arm() {
state.visibleParts = visibleParts()
state.armed = true
},
}
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
const sample = () => {
if (state.armed) {
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
const view = viewport?.getBoundingClientRect()
const visible = (partID: string) => {
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(partID)}"]`)
const rect = part?.getBoundingClientRect()
return (
!!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
)
await page.addInitScript(
({ userPartID, lastPartID }) => {
const state = { armed: false, hidden: false, samples: 0, stop: false }
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
const sample = () => {
if (state.armed) {
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
const view = viewport?.getBoundingClientRect()
const visible = (partID: string) => {
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
const rect = part?.getBoundingClientRect()
return (
!!rect &&
!!view &&
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > view.top &&
rect.top < view.bottom
)
}
if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true
state.samples++
}
if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID)))
state.hidden = true
state.samples++
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
}
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
}
requestAnimationFrame(() => setTimeout(sample, 0))
})
requestAnimationFrame(() => setTimeout(sample, 0))
},
{ userPartID, lastPartID },
)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection()
@@ -160,28 +143,23 @@ for (const scenario of scenarios) {
"messages:start:latest",
"messages:end:latest",
`message:${userID}`,
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
`messages:start:${messages.at(-2)!.info.id}`,
])
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
await page.evaluate(() => {
;(
window as Window & {
__historyRootProbe?: { arm(): void }
__historyRootProbe?: { armed: boolean }
}
).__historyRootProbe!.arm()
).__historyRootProbe!.armed = true
})
await waitForProbeSamples(page, 0)
expect(await visibleContentHidden(page)).toBe(false)
expect(await historyRootHidden(page)).toBe(false)
const beforeHistory = await probeSamples(page)
history.resolve()
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14)
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await waitForProbeSamples(page, beforeHistory)
expect(pages).toEqual([
{ before: undefined, limit: initialPageSize },
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
])
expect(pages[0]).toEqual({ before: undefined, limit: 2 })
expect(roots).toEqual([{ sessionID, messageID: userID }])
const message = messageUpdated(scenario.info)
@@ -235,7 +213,7 @@ async function waitForProbeSamples(page: Page, after: number) {
)
}
function visibleContentHidden(page: Page) {
function historyRootHidden(page: Page) {
return page.evaluate(
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
)
@@ -150,10 +150,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,5 +1,5 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -7,11 +7,10 @@ const directory = "C:/OpenCode/TerminalComposerFocus"
const projectID = "proj_terminal_composer_focus"
const sessionID = "ses_terminal_composer_focus"
const ptyID = "pty_terminal_composer_focus"
const newPtyID = "pty_terminal_composer_focus_new"
test.use({ viewport: { width: 1440, height: 900 } })
test.beforeEach(async ({ page }) => {
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -68,9 +67,7 @@ test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
})
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
@@ -90,120 +87,3 @@ test("routes typing to the composer unless the open terminal is focused", async
await expect(composer).toBeFocused()
await expect(composer).toHaveText("a")
})
test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => {
const ghostty = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const created = { count: 0 }
await page.route("**/pty", (route) => {
created.count += 1
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
})
})
await page.route(/ghostty-web/, async (route) => {
ghostty.resolve()
await release.promise
await route.continue()
})
await seedCachedTerminal(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toBeVisible()
expect(created.count).toBe(0)
await ghostty.promise
await composer.click()
await expect(composer).toBeFocused()
release.resolve()
await expect(terminal.locator("textarea")).toHaveCount(1)
await page.waitForTimeout(300)
await expect(composer).toBeFocused()
})
test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => {
const ghostty = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(/ghostty-web/, async (route) => {
ghostty.resolve()
await release.promise
await route.continue()
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await ghostty.promise
await composer.click()
await expect(composer).toBeFocused()
release.resolve()
await expect(terminal.locator("textarea")).toHaveCount(1)
await page.waitForTimeout(50)
await expect(composer).toBeFocused()
})
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
const created = { count: 0 }
await page.route("**/pty", (route) => {
created.count += 1
const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(next),
})
})
await page.route(`**/pty/${newPtyID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ ticket: "e2e-ticket" }),
}),
)
await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal.locator("textarea")).toHaveCount(1)
await composer.click()
await expect(composer).toBeFocused()
await page.getByRole("button", { name: "New terminal" }).click()
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
})
function seedCachedTerminal(page: Page) {
return page.addInitScript(
({ terminalKey, ptyID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
localStorage.setItem(
terminalKey,
JSON.stringify({
active: ptyID,
all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }],
}),
)
},
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
)
}
@@ -1,34 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Timeline Suspense Reproduction</title>
<style>
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
}
body {
background: #171717;
color: #f5f5f5;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
#root {
padding: 24px;
}
</style>
</head>
<body>
<main id="root"></main>
<script type="module" src="/main.tsx"></script>
</body>
</html>
@@ -1,315 +0,0 @@
import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
import { render } from "solid-js/web"
import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
const rowCount = 2_000
const rowHeight = 40
const parameters = new URLSearchParams(location.search)
const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline"
const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline"
type MutationEvent = {
kind: "removed" | "added"
callbackTime: number
callbackFrame: number
routeConnectedInCallback: boolean
nativeOffsetInCallback: number
}
type Snapshot = {
mode: {
resource: "baseline" | "guard"
reconnect: "baseline" | "candidate"
}
operation: {
sequence: number
phase: string
time: number
frame: number
}
resourceState: string
routeConnected: boolean
viewportConnected: boolean
viewportOwnedByRoute: boolean
sameRoute: boolean
sameViewport: boolean
sameSurface: boolean
sameMountedRows: boolean
nativeOffset: number
coreOffset: number
rangeStart: number
rangeEnd: number
indexes: number[]
domIndexes: number[]
logicalSurfaceHeight: number
renderedSurfaceHeight: number
viewportClientHeight: number
viewportScrollHeight: number
visibleRows: number
minimumRowTop: number
domScrollEvents: number
lastScrollTrusted: boolean
coreOffsetCallbackCalls: number
offsetCallbackSources: "observer"[]
rectObserverCallbacks: number
ignoredDetachedZeroRects: number
syntheticScrollDispatches: number
mutationEvents: MutationEvent[]
}
declare global {
interface Window {
timelineSuspense: {
prepare: () => Promise<Snapshot>
trigger: () => void
resolve: () => void
frames: (count?: number) => Promise<void>
snapshot: () => Snapshot
}
}
}
function App() {
const [refresh, setRefresh] = createSignal(false)
let resolveResource: (() => void) | undefined
const [resource] = createResource(
refresh,
(version) =>
new Promise<string>((resolve) => {
resolveResource = () => resolve(`settled-${version}`)
}),
{ initialValue: "settled" },
)
function Route() {
let route: HTMLElement | undefined
let viewport: HTMLDivElement | undefined
let surface: HTMLDivElement | undefined
let initialRoute: HTMLElement | undefined
let initialViewport: HTMLDivElement | undefined
let initialSurface: HTMLDivElement | undefined
let initialRows: HTMLElement[] = []
let phase = "mounting"
let browserFrame = 0
let snapshotSequence = 0
let domScrollEvents = 0
let lastScrollTrusted = false
let coreOffsetCallbackCalls = 0
let rectObserverCallbacks = 0
let ignoredDetachedZeroRects = 0
const offsetCallbackSources: "observer"[] = []
const mutationEvents: MutationEvent[] = []
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
count: rowCount,
getScrollElement: () => viewport ?? null,
estimateSize: () => rowHeight,
initialRect: { width: 900, height: 600 },
overscan: 2,
observeElementRect: (instance, callback) =>
observeElementRect(instance, (rect) => {
rectObserverCallbacks++
// A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect.
if (!instance.scrollElement?.isConnected && rect.height === 0) {
ignoredDetachedZeroRects++
return
}
callback(rect)
}),
observeElementOffset: (instance, callback) => {
const deliver = (offset: number, isScrolling: boolean) => {
coreOffsetCallbackCalls++
offsetCallbackSources.push("observer")
callback(offset, isScrolling)
}
if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver)
return observeElementOffset(instance, deliver)
},
})
const frames = async (count = 2) => {
for (let index = 0; index < count; index++) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
}
const mountedRows = () => [...(surface?.querySelectorAll<HTMLElement>("[data-row-index]") ?? [])]
const snapshot = (): Snapshot => {
const rows = mountedRows()
const view = viewport?.getBoundingClientRect()
const visibleRows =
viewport?.isConnected && view
? rows.filter((row) => {
const rect = row.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
}).length
: 0
return {
mode: { resource: resourceMode, reconnect: reconnectMode },
operation: {
sequence: ++snapshotSequence,
phase,
time: performance.now(),
frame: browserFrame,
},
resourceState: resource.state,
routeConnected: route?.isConnected ?? false,
viewportConnected: viewport?.isConnected ?? false,
viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport),
sameRoute: route === initialRoute,
sameViewport: viewport === initialViewport,
sameSurface: surface === initialSurface,
sameMountedRows:
initialRows.length > 0 &&
initialRows.length === rows.length &&
initialRows.every((row, index) => row === rows[index]),
nativeOffset: viewport?.scrollTop ?? -1,
coreOffset: virtualizer.scrollOffset ?? -1,
rangeStart: virtualizer.range?.startIndex ?? -1,
rangeEnd: virtualizer.range?.endIndex ?? -1,
indexes: virtualizer.getVirtualItems().map((item) => item.index),
domIndexes: rows.map((row) => Number(row.dataset.rowIndex)),
logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"),
renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1,
viewportClientHeight: viewport?.clientHeight ?? -1,
viewportScrollHeight: viewport?.scrollHeight ?? -1,
visibleRows,
minimumRowTop:
rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1,
domScrollEvents,
lastScrollTrusted,
coreOffsetCallbackCalls,
offsetCallbackSources: [...offsetCallbackSources],
rectObserverCallbacks,
ignoredDetachedZeroRects,
syntheticScrollDispatches: 0,
mutationEvents: mutationEvents.map((event) => ({ ...event })),
}
}
onMount(() => {
if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount")
const routeRoot = route.parentElement
if (!routeRoot) throw new Error("Timeline route root did not mount")
initialRoute = route
initialViewport = viewport
initialSurface = surface
viewport.addEventListener("scroll", (event) => {
domScrollEvents++
lastScrollTrusted = event.isTrusted
})
const countFrames = () => {
browserFrame++
requestAnimationFrame(countFrames)
}
requestAnimationFrame(countFrames)
new MutationObserver((records) => {
const callbackTime = performance.now()
records.forEach((record) => {
;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => {
if (node !== route) return
phase = "detached"
mutationEvents.push({
kind: "removed",
callbackTime,
callbackFrame: browserFrame,
routeConnectedInCallback: route.isConnected,
nativeOffsetInCallback: viewport.scrollTop,
})
})
;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => {
if (node !== route) return
phase = "reinserted"
mutationEvents.push({
kind: "added",
callbackTime,
callbackFrame: browserFrame,
routeConnectedInCallback: route.isConnected,
nativeOffsetInCallback: viewport.scrollTop,
})
})
})
}).observe(routeRoot, { childList: true })
window.timelineSuspense = {
prepare: async () => {
phase = "preparing"
await frames(2)
viewport.scrollTop = viewport.scrollHeight
await frames(3)
await new Promise((resolve) => setTimeout(resolve, 200))
await frames(2)
initialRows = mountedRows()
phase = "prepared"
return snapshot()
},
trigger: () => {
phase = "triggering"
setRefresh(true)
},
resolve: () => {
if (!resolveResource) throw new Error("Resource is not pending")
phase = "resolving"
resolveResource()
},
frames,
snapshot,
}
})
return (
<section ref={route} data-route style={{ width: "900px", margin: "0 auto" }}>
<span aria-hidden="true" style={{ display: "none" }}>
{resourceMode === "guard" && resource.state === "refreshing" ? resource.latest : resource()}
</span>
<div
ref={viewport}
data-viewport
style={{
height: "600px",
overflow: "auto",
"overflow-anchor": "none",
position: "relative",
background: "#202020",
outline: "1px solid #3f3f46",
}}
>
<div
ref={surface}
data-surface
style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", "overflow-anchor": "none" }}
>
<For each={virtualizer.getVirtualItems()}>
{(item) => (
<div
data-row-index={item.index}
style={{
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: `${item.size}px`,
transform: `translateY(${item.start}px)`,
padding: "10px 14px",
border: "0 solid #333",
"border-bottom-width": "1px",
}}
>
logical row {item.index}
</div>
)}
</For>
</div>
</div>
</section>
)
}
return (
<main>
<Suspense>
<Route />
</Suspense>
</main>
)
}
render(() => <App />, document.getElementById("root")!)
@@ -1,34 +0,0 @@
import { defineConfig, devices } from "@playwright/test"
const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317)
export default defineConfig({
testDir: ".",
testMatch: "timeline-suspense.repro.ts",
outputDir: "../../test-results/timeline-suspense",
fullyParallel: false,
workers: 1,
retries: 0,
reporter: "line",
timeout: 30_000,
expect: {
timeout: 10_000,
},
webServer: {
command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`,
cwd: import.meta.dirname,
url: `http://127.0.0.1:${port}`,
reuseExistingServer: false,
},
use: {
baseURL: `http://127.0.0.1:${port}`,
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
})
@@ -1,179 +0,0 @@
import { expect, test, type Page } from "@playwright/test"
test.beforeEach(async ({ page }) => {
page.on("pageerror", (error) => console.error(error))
await page.goto("/")
await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true)
})
test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => {
await page.goto("/?reconnect=candidate")
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate")
const before = await prepare(page)
await triggerBaselineSuspension(page)
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
expect(pending.nativeOffset).toBe(0)
expect(pending.coreOffset).toBe(before.coreOffset)
expect(pending.indexes).toEqual(before.indexes)
expect(pending.sameRoute).toBe(true)
expect(pending.sameViewport).toBe(true)
expect(pending.sameSurface).toBe(true)
expect(pending.sameMountedRows).toBe(true)
await resolveSuspension(page)
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0)
await page.waitForTimeout(250)
await page.evaluate(() => window.timelineSuspense.frames(2))
const after = await page.evaluate(() => window.timelineSuspense.snapshot())
expect(after.sameRoute).toBe(true)
expect(after.sameViewport).toBe(true)
expect(after.sameSurface).toBe(true)
expect(after.nativeOffset).toBe(0)
expect(after.coreOffset).toBe(0)
expect(after.rangeStart).toBeLessThan(10)
expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0)
expect(after.domScrollEvents).toBe(before.domScrollEvents)
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1)
expect(after.offsetCallbackSources.at(-1)).toBe("observer")
expect(after.syntheticScrollDispatches).toBe(0)
})
test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({
page,
}) => {
const before = await prepare(page)
const beforeRows = before.domIndexes
expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" })
expect(before.logicalSurfaceHeight).toBe(80_000)
expect(before.renderedSurfaceHeight).toBe(80_000)
expect(before.viewportClientHeight).toBe(600)
expect(before.viewportScrollHeight).toBe(80_000)
expect(before.rangeStart).toBeGreaterThan(1_900)
expect(before.nativeOffset).toBe(before.coreOffset)
expect(before.visibleRows).toBeGreaterThan(0)
await triggerBaselineSuspension(page)
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
expect(pending.resourceState).toBe("refreshing")
expect(pending.routeConnected).toBe(false)
expect(pending.viewportConnected).toBe(false)
expect(pending.viewportOwnedByRoute).toBe(true)
expect(pending.nativeOffset).toBe(0)
expect(pending.coreOffset).toBe(before.coreOffset)
expect(pending.rangeStart).toBe(before.rangeStart)
expect(pending.rangeEnd).toBe(before.rangeEnd)
expect(pending.indexes).toEqual(before.indexes)
expect(pending.domIndexes).toEqual(beforeRows)
expect(pending.sameMountedRows).toBe(true)
expect(pending.domScrollEvents).toBe(before.domScrollEvents)
expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects)
expect(pending.mutationEvents).toHaveLength(1)
expect(pending.mutationEvents[0]).toMatchObject({
kind: "removed",
routeConnectedInCallback: false,
nativeOffsetInCallback: 0,
})
expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time)
expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame)
const after = await resolveSuspension(page)
expect(after.resourceState).toBe("ready")
expect(after.routeConnected).toBe(true)
expect(after.viewportConnected).toBe(true)
expect(after.viewportOwnedByRoute).toBe(true)
expect(after.sameRoute).toBe(true)
expect(after.sameViewport).toBe(true)
expect(after.sameSurface).toBe(true)
expect(after.sameMountedRows).toBe(true)
expect(after.nativeOffset).toBe(0)
expect(after.coreOffset).toBe(before.coreOffset)
expect(after.rangeStart).toBe(before.rangeStart)
expect(after.rangeEnd).toBe(before.rangeEnd)
expect(after.indexes).toEqual(before.indexes)
expect(after.domIndexes).toEqual(beforeRows)
expect(after.domScrollEvents).toBe(before.domScrollEvents)
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
expect(after.mutationEvents).toHaveLength(2)
expect(after.mutationEvents[1]).toMatchObject({
kind: "added",
routeConnectedInCallback: true,
nativeOffsetInCallback: 0,
})
expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time)
expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame)
expect(after.visibleRows).toBe(0)
expect(after.minimumRowTop).toBeGreaterThan(50_000)
expect(after.syntheticScrollDispatches).toBe(0)
await page.locator("[data-viewport]").hover()
await page.mouse.wheel(0, 80)
await expect
.poll(() =>
page.evaluate(() => {
const value = window.timelineSuspense.snapshot()
return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset
}),
)
.toBe(true)
await page.evaluate(() => window.timelineSuspense.frames(2))
const recovered = await page.evaluate(() => window.timelineSuspense.snapshot())
expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents)
expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls)
expect(recovered.offsetCallbackSources.at(-1)).toBe("observer")
expect(recovered.lastScrollTrusted).toBe(true)
expect(recovered.rangeStart).toBeLessThan(10)
expect(recovered.visibleRows).toBeGreaterThan(0)
})
test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => {
await page.goto("/?resource=guard")
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard")
const before = await prepare(page)
await page.evaluate(() => window.timelineSuspense.trigger())
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
await page.evaluate(() => window.timelineSuspense.frames(3))
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
expect(pending.routeConnected).toBe(true)
expect(pending.mutationEvents).toEqual([])
expect(pending.nativeOffset).toBe(before.nativeOffset)
expect(pending.coreOffset).toBe(before.coreOffset)
expect(pending.visibleRows).toBeGreaterThan(0)
const after = await resolveSuspension(page)
expect(after.routeConnected).toBe(true)
expect(after.nativeOffset).toBe(before.nativeOffset)
expect(after.coreOffset).toBe(before.coreOffset)
expect(after.visibleRows).toBeGreaterThan(0)
})
async function prepare(page: Page) {
const before = await page.evaluate(() => window.timelineSuspense.prepare())
expect(before.routeConnected).toBe(true)
expect(before.viewportConnected).toBe(true)
expect(before.viewportOwnedByRoute).toBe(true)
expect(before.sameMountedRows).toBe(true)
expect(before.rangeStart).toBeGreaterThan(1_900)
expect(before.nativeOffset).toBe(before.coreOffset)
return before
}
async function triggerBaselineSuspension(page: Page) {
await page.evaluate(() => window.timelineSuspense.trigger())
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false)
await page.evaluate(() => window.timelineSuspense.frames(3))
}
async function resolveSuspension(page: Page) {
await page.evaluate(() => window.timelineSuspense.resolve())
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready")
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true)
await page.evaluate(() => window.timelineSuspense.frames(3))
return page.evaluate(() => window.timelineSuspense.snapshot())
}
function diagnostic(value: unknown) {
return JSON.stringify(value, null, 2)
}
@@ -1,7 +0,0 @@
import { defineConfig } from "vite"
import solid from "vite-plugin-solid"
export default defineConfig({
root: import.meta.dirname,
plugins: [solid()],
})
-3
View File
@@ -10,9 +10,6 @@
"./performance/timeline-stability/fixture.test.ts",
"./performance/timeline-stability/fixture.ts",
"./performance/unit/visual-stability.test.ts",
"./reproduction/timeline-suspense/**/*.ts",
"./reproduction/timeline-suspense/**/*.tsx",
"../src/pages/session/timeline/observe-element-offset.ts",
"./regression/new-session-panel-corner.spec.ts",
"./regression/session-timeline-context-resize.spec.ts",
"./utils/**/*.ts"
+1 -1
View File
@@ -162,7 +162,7 @@ export async function installSseTransport<T>(
const request = new Request(input, init)
const url = new URL(request.url)
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
return originalFetch(request)
return originalFetch(input, init)
const id = ++nextConnectionID
const record = {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.20",
"version": "1.17.18",
"description": "",
"type": "module",
"exports": {
+27 -17
View File
@@ -153,7 +153,8 @@ function LegacyTargetSessionRedirect() {
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell for that server.
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server.
function SelectedServerProviders(props: ParentProps) {
return (
<ServerKey>
@@ -206,7 +207,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ModelsProvider directory={directory}>
<DraftServerScopedProviders directory={directory}>
<SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders>
@@ -214,7 +215,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
</ModelsProvider>
</DraftServerScopedProviders>
</ServerSyncProvider>
</ServerSDKProvider>
</Show>
@@ -308,21 +309,24 @@ function DesktopCommands() {
// Server-scoped providers shared by the legacy shell and the top-level new shell.
type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
sessionID?: () => string | undefined
serverScoped?: JSX.Element
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
return (
<LayoutProvider>
{props.serverScoped}
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</LayoutProvider>
<PermissionProvider directory={props.directory}>
<LayoutProvider>
{props.serverScoped}
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</LayoutProvider>
</PermissionProvider>
)
}
function LegacyServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
<LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders>
)
@@ -338,6 +342,14 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
)
}
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
return (
<PermissionProvider directory={props.directory}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</PermissionProvider>
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) {
@@ -547,15 +559,13 @@ export function AppInterface(props: {
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<PermissionProvider>
<NotificationProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</NotificationProvider>
</PermissionProvider>
<NotificationProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</NotificationProvider>
</TabsProvider>
)}
>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

@@ -1,156 +0,0 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Field } from "@opencode-ai/ui/v2/field-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { createEditProjectModel } from "./edit-project"
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
const language = useLanguage()
const model = createEditProjectModel(props)
return (
<Dialog fit>
<form onSubmit={model.submit} class="contents">
<DialogHeader>
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
<Field>
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
<TextInputV2
autofocus
appearance="large"
class="!w-full"
value={model.store.name}
placeholder={model.folderName()}
onInput={(event) => model.setStore("name", event.currentTarget.value)}
/>
</Field>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.icon")}
</div>
<div class="flex items-center gap-3">
<button
type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
}}
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
src={getProjectAvatarSource(props.project.id, {
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
})}
variant={getProjectAvatarVariant(model.store.color)}
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
/>
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
classList={{
"opacity-100": model.store.iconHover,
"opacity-0": !model.store.iconHover,
}}
>
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
</span>
</button>
<input
ref={(element) => {
model.setIconInput(element)
}}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
/>
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
</div>
<Show when={!model.store.iconOverride}>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.color")}
</div>
<div class="-ml-1 flex gap-1.5">
<For each={PROJECT_AVATAR_VARIANTS}>
{(color) => (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
getProjectAvatarVariant(model.store.color) === color,
}}
onClick={() => {
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
model.setStore(
"color",
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
)
}}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
variant={getProjectAvatarVariant(color)}
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)}
</For>
</div>
</div>
</Show>
<Field>
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
<TextareaV2
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={3}
value={model.store.startup}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
spellcheck={false}
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
/>
</Field>
</DialogBody>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</ButtonV2>
</DialogFooter>
</form>
</Dialog>
)
}
@@ -1,32 +1,123 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon"
import { For, Show } from "solid-js"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/core/util/path"
import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerConnection } from "@/context/server"
import { createEditProjectModel } from "./edit-project"
import { useGlobal } from "@/context/global"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
const dialog = useDialog()
const global = useGlobal()
const language = useLanguage()
const model = createEditProjectModel(props)
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
const serverSDK = () => serverCtx().sdk
const serverSync = () => serverCtx().sync
const folderName = createMemo(() => getFilename(props.project.worktree))
const defaultName = createMemo(() => props.project.name || folderName())
const [store, setStore] = createStore({
name: defaultName(),
color: props.project.icon?.color,
iconOverride: props.project.icon?.override,
startup: props.project.commands?.start ?? "",
dragOver: false,
iconHover: false,
})
let iconInput: HTMLInputElement | undefined
function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
setStore("iconOverride", e.target?.result as string)
setStore("iconHover", false)
}
reader.readAsDataURL(file)
}
function handleDrop(e: DragEvent) {
e.preventDefault()
setStore("dragOver", false)
const file = e.dataTransfer?.files[0]
if (file) handleFileSelect(file)
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
setStore("dragOver", true)
}
function handleDragLeave() {
setStore("dragOver", false)
}
function handleInputChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (file) handleFileSelect(file)
}
function clearIcon() {
setStore("iconOverride", "")
}
const saveMutation = useMutation(() => ({
mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await serverSDK().client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
serverSync().project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
dialog.close()
},
}))
function handleSubmit(e: SubmitEvent) {
e.preventDefault()
if (saveMutation.isPending) return
saveMutation.mutate()
}
return (
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
<div class="flex flex-col gap-4">
<TextField
autofocus
type="text"
label={language.t("dialog.project.edit.name")}
placeholder={model.folderName()}
value={model.store.name}
onChange={(v) => model.setStore("name", v)}
placeholder={folderName()}
value={store.name}
onChange={(v) => setStore("name", v)}
/>
<div class="flex flex-col gap-2">
@@ -34,32 +125,38 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<div class="flex gap-3 items-start">
<div
class="relative"
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
onMouseEnter={() => setStore("iconHover", true)}
onMouseLeave={() => setStore("iconHover", false)}
>
<div
class="relative size-16 rounded-md transition-colors cursor-pointer"
classList={{
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
"border-border-base hover:border-border-strong": !model.store.dragOver,
"overflow-hidden": !!model.store.iconOverride,
"border-text-interactive-base bg-surface-info-base/20": store.dragOver,
"border-border-base hover:border-border-strong": !store.dragOver,
"overflow-hidden": !!store.iconOverride,
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => {
if (store.iconOverride && store.iconHover) {
clearIcon()
} else {
iconInput?.click()
}
}}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<Show
when={getProjectAvatarSource(props.project.id, {
color: model.store.color,
color: store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
override: store.iconOverride,
})}
fallback={
<div class="size-full flex items-center justify-center">
<Avatar
fallback={model.store.name || model.defaultName()}
{...getAvatarColors(model.store.color)}
fallback={store.name || defaultName()}
{...getAvatarColors(store.color)}
class="size-full text-[32px]"
/>
</div>
@@ -77,8 +174,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": model.store.iconHover && !model.store.iconOverride,
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
"opacity-100": store.iconHover && !store.iconOverride,
"opacity-0": !(store.iconHover && !store.iconOverride),
}}
>
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
@@ -86,8 +183,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
"opacity-100": store.iconHover && !!store.iconOverride,
"opacity-0": !(store.iconHover && !!store.iconOverride),
}}
>
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
@@ -96,12 +193,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<input
id="icon-upload"
ref={(el) => {
model.setIconInput(el)
iconInput = el
}}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
onChange={handleInputChange}
/>
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
@@ -110,7 +207,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
</div>
</div>
<Show when={!model.store.iconOverride}>
<Show when={!store.iconOverride}>
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
<div class="flex gap-1.5">
@@ -119,21 +216,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={model.store.color === color}
aria-pressed={store.color === color}
classList={{
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
model.store.color === color,
store.color === color,
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
model.store.color !== color,
store.color !== color,
}}
onClick={() => {
if (model.store.color === color && !props.project.icon?.url) return
model.setStore("color", model.store.color === color ? undefined : color)
if (store.color === color && !props.project.icon?.url) return
setStore("color", store.color === color ? undefined : color)
}}
>
<Avatar
fallback={model.store.name || model.defaultName()}
fallback={store.name || defaultName()}
{...getAvatarColors(color)}
class="size-full rounded"
/>
@@ -149,19 +246,19 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
label={language.t("dialog.project.edit.worktree.startup")}
description={language.t("dialog.project.edit.worktree.startup.description")}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
value={model.store.startup}
onChange={(v) => model.setStore("startup", v)}
value={store.startup}
onChange={(v) => setStore("startup", v)}
spellcheck={false}
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
/>
</div>
<div class="flex justify-end gap-2">
<Button type="button" variant="ghost" size="large" onClick={model.close}>
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
</Button>
</div>
</form>
-119
View File
@@ -1,119 +0,0 @@
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global"
import { type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const dialog = useDialog()
const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
const folderName = createMemo(() => getFilename(props.project.worktree))
const defaultName = createMemo(() => props.project.name || folderName())
const [store, setStore] = createStore({
name: defaultName(),
color: props.project.icon?.color,
iconOverride: props.project.icon?.override,
startup: props.project.commands?.start ?? "",
dragOver: false,
iconHover: false,
})
let iconInput: HTMLInputElement | undefined
function selectFile(file: File) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (event) => {
const result = event.target?.result
if (typeof result !== "string") return
setStore("iconOverride", result)
setStore("iconHover", false)
}
reader.readAsDataURL(file)
}
function drop(event: DragEvent) {
event.preventDefault()
setStore("dragOver", false)
const file = event.dataTransfer?.files[0]
if (file) selectFile(file)
}
function dragOver(event: DragEvent) {
event.preventDefault()
setStore("dragOver", true)
}
function dragLeave() {
setStore("dragOver", false)
}
function inputChange(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0]
if (file) selectFile(file)
}
function iconClick() {
if (store.iconOverride && store.iconHover) {
setStore("iconOverride", "")
return
}
iconInput?.click()
}
const save = useMutation(() => ({
mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await serverCtx().sdk.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
serverCtx().sync.project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
dialog.close()
},
}))
function submit(event: SubmitEvent) {
event.preventDefault()
if (save.isPending) return
save.mutate()
}
return {
store,
setStore,
folderName,
defaultName,
save,
submit,
drop,
dragOver,
dragLeave,
inputChange,
iconClick,
close() {
dialog.close()
},
setIconInput(input: HTMLInputElement) {
iconInput = input
},
}
}
@@ -1,9 +1,8 @@
import { describe, expect, test } from "bun:test"
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
import type { FileNode } from "@opencode-ai/sdk/v2"
import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model"
describe("buildFileTreeV2Model", () => {
test("builds a sorted tree and flattens expanded directories", () => {
describe("file tree v2 model", () => {
test("builds sorted depth-first rows", () => {
const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
expect(model.total).toBe(8)
@@ -19,7 +18,7 @@ describe("buildFileTreeV2Model", () => {
])
})
test("skips children of collapsed directories", () => {
test("omits descendants of collapsed directories", () => {
const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
@@ -29,46 +28,19 @@ describe("buildFileTreeV2Model", () => {
])
})
test("normalizes duplicate and messy paths", () => {
test("normalizes separators and duplicate paths", () => {
const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
const rows = flattenFileTreeV2(model, () => true)
expect(model.total).toBe(4)
expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
})
test("handles deeply nested paths", () => {
const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts"
test("supports paths deeper than the legacy recursion limit", () => {
const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts`
const model = buildFileTreeV2Model([file])
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
})
})
describe("flattenLiveFileTreeV2", () => {
test("flattens live children using original paths for nested lookups", () => {
const nodes: Record<string, FileNode[]> = {
"": [
{ name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false },
{ name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false },
],
src: [
{ name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false },
{ name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false },
],
"src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }],
}
expect(
flattenLiveFileTreeV2(
(path) => nodes[path] ?? [],
(path) => path === "src",
).map((row) => [row.node.path, row.node.originalPath, row.level]),
).toEqual([
["src", "src", 0],
["src/a.ts", "src/a.ts", 1],
["src/lib", "src/lib", 1],
["README.md", "README.md", 0],
])
})
})
@@ -75,33 +75,3 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin
return rows
}
export function flattenLiveFileTreeV2(
children: (path: string) => readonly FileNode[],
expanded: (path: string) => boolean,
) {
const rows: FileTreeV2Row[] = []
const stack = children("")
.toReversed()
.map((node) => ({ node: toLiveNode(node), level: 0 }))
while (stack.length > 0) {
const row = stack.pop()!
rows.push(row)
if (row.node.type !== "directory" || !expanded(row.node.path)) continue
const nested = children(row.node.originalPath)
for (let index = nested.length - 1; index >= 0; index--) {
stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 })
}
}
return rows
}
function toLiveNode(node: FileNode): FileTreeV2Node {
return {
...node,
path: normalizeFileTreeV2Path(node.path),
originalPath: node.path,
}
}
+29 -60
View File
@@ -16,13 +16,7 @@ import type { FileNode } from "@opencode-ai/sdk/v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import {
buildFileTreeV2Model,
flattenFileTreeV2,
flattenLiveFileTreeV2,
normalizeFileTreeV2Path,
type FileTreeV2Node,
} from "@/components/file-tree-v2-model"
import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
import { virtualScrollElement } from "@/components/virtual-scroll-element"
export type { Kind } from "@/components/file-tree"
@@ -42,7 +36,7 @@ function guideLineLeft(level: number) {
export const kindLabel = (kind: Kind) => {
if (kind === "add") return "A"
if (kind === "del") return "D"
return "M"
return ""
}
export const kindChange = (kind: Kind) => {
@@ -74,7 +68,7 @@ const FileTreeNodeV2 = (
"class",
"classList",
])
const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path))
const kind = () => local.kinds?.get(local.node.path)
return (
<Dynamic
@@ -116,7 +110,12 @@ const FileTreeNodeV2 = (
function GuideLines(props: { level: number }) {
return (
<For each={Array.from({ length: props.level })}>
{(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />}
{(_, index) => (
<div
class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
style={`left: ${guideLineLeft(index())}px`}
/>
)}
</For>
)
}
@@ -127,18 +126,12 @@ export default function FileTreeV2(props: {
kinds?: ReadonlyMap<string, Kind>
draggable?: boolean
onFileClick?: (file: FileNode) => void
onFileDoubleClick?: (file: FileNode) => void
}) {
const file = useFile()
const live = () => props.allowed === undefined
const draggable = () => props.draggable ?? true
const active = () => normalizeFileTreeV2Path(props.active ?? "")
const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? [])))
const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live()
const rows = createMemo(() => {
if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded)
return flattenFileTreeV2(model()!, expanded)
})
const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? []))
const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true))
const [root, setRoot] = createSignal<HTMLDivElement>()
const [focused, setFocused] = createSignal<string>()
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
@@ -162,49 +155,16 @@ export default function FileTreeV2(props: {
return [...indexes, index].sort((a, b) => a - b)
},
})
createEffect(() => {
if (!live()) return
void file.tree.list("")
})
// Only scroll when the active path changes (or first appears in the tree).
// Do not re-scroll when expand/collapse reshuffles `rows()`.
let scrolledActive: string | undefined
createEffect(() => {
const path = active()
if (!path) {
scrolledActive = undefined
return
}
if (!path) return
const index = rows().findIndex((row) => row.node.path === path)
if (index < 0) return
if (scrolledActive === path) return
scrolledActive = path
queueMicrotask(() => {
const next = rows().findIndex((row) => row.node.path === path)
if (next < 0) return
if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return
virtualizer.scrollToIndex(next, { align: "auto" })
if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
virtualizer.scrollToIndex(index, { align: "auto" })
})
})
const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => {
action?.({
...node,
path: node.originalPath,
absolute: node.originalPath,
})
}
const toggleDirectory = (path: string, originalPath: string) => {
if (expanded(path)) {
file.tree.collapse(originalPath)
return
}
file.tree.expand(originalPath, live() ? undefined : { list: false })
}
const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
@@ -215,7 +175,7 @@ export default function FileTreeV2(props: {
<div
ref={setRoot}
data-component="file-tree-v2"
data-total-rows={live() ? rows().length : model()!.total}
data-total-rows={model().total}
class="group/file-tree-v2"
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
>
@@ -249,8 +209,13 @@ export default function FileTreeV2(props: {
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() => selectFile(row().node, props.onFileClick)}
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
onClick={() =>
props.onFileClick?.({
...row().node,
path: row().node.originalPath,
absolute: row().node.originalPath,
})
}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
@@ -274,13 +239,17 @@ export default function FileTreeV2(props: {
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
aria-expanded={expanded(row().node.path)}
onClick={() => toggleDirectory(row().node.path, row().node.originalPath)}
aria-expanded={file.tree.state(row().node.path)?.expanded ?? true}
onClick={() =>
file.tree.state(row().node.path)?.expanded === false
? file.tree.expand(row().node.path, { list: false })
: file.tree.collapse(row().node.path)
}
>
<GuideLines level={row().level} />
<div
data-slot="file-tree-v2-chevron"
data-expanded={expanded(row().node.path) ? "" : undefined}
data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
class="size-4 flex items-center justify-center"
>
<Icon name="chevron-down" />
+19 -33
View File
@@ -1,12 +1,11 @@
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.png"
import { Persist, persisted } from "@/utils/persist"
const helpIcon = (
<svg
@@ -55,21 +54,24 @@ export function HelpButton() {
// can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() {
const settings = useSettings()
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
// setState({ dismissed: false }) // for testing
const [drawerOpen, setDrawerOpen] = createSignal(false)
return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Show when={settings.general.shouldDisplayTabsToast()}>
<Show when={!state.dismissed}>
<div
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
aria-label="Introducing Tabs. A faster, more intuitive way to work."
>
<button
type="button"
aria-label="Dismiss Tabs information"
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
onClick={settings.general.dismissTabsToast}
onClick={() => setState("dismissed", true)}
>
<svg
width="16"
@@ -86,7 +88,7 @@ export function TabsInfoPopup() {
type="button"
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
onClick={() => {
settings.general.dismissTabsToast()
setState("dismissed", true)
setDrawerOpen(true)
}}
>
@@ -105,7 +107,7 @@ export function TabsInfoPopup() {
Introducing Tabs
</p>
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
Organize your work and active sessions with tabs
A faster, more intuitive way to work.
</p>
</div>
</button>
@@ -125,32 +127,16 @@ export function TabsInfoPopup() {
icon={<IconV2 name="xmark-small" />}
/>
</div>
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
<div class="relative flex w-full flex-col items-start gap-6 p-8">
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
Introducing Tabs
Introducing Tabs Navigation.
</p>
<p class="w-full flex-1 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
We've introduced tabs as the primary navigation in OpenCode. Your most important session are now pinned at
the top of your screen at all times. No more hunting through menus or losing your place mid-session. Switch
contexts instantly, pick up exactly where you left off, and keep your focus where it belongs: on the
sessions.
</p>
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<p>OpenCode Desktop is now built around tabs.</p>
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
you're starting something new, and close it when you're done.
</p>
<p>
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
memorable if you plan to keep them around.
</p>
<p>
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
</p>
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>When you reopen the app, your tabs are still open.</p>
<p>
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
the previous layout , you can switch between layouts in Settings. Just keep in mind that the new layout
will become permanent in a few weeks.
</p>
</div>
</div>
</DrawerContent>
</Drawer>
+18 -47
View File
@@ -19,7 +19,6 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/f
import {
ContentPart,
DEFAULT_PROMPT,
isCommentItem,
isPromptEqual,
Prompt,
usePrompt,
@@ -633,9 +632,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const handleBlur = () => {
const cursor = currentCursor()
savedCursor = cursor
if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor)
savedCursor = currentCursor()
closePopover()
setComposing(false)
}
@@ -1629,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
@@ -1639,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
@@ -1649,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]"
@@ -1867,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
@@ -1877,7 +1861,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
newLayoutDesigns={props.controls.newLayoutDesigns}
/>
<div
class="relative"
@@ -2203,7 +2186,10 @@ type ComposerModelControlState = {
function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
return (
<div>
<div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
<Icon name="sliders" size="small" />
</div>
<TooltipV2
placement="top"
gutter={4}
@@ -2214,32 +2200,17 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
</>
}
>
<MenuV2 gutter={6} modal={false} placement="top-start">
<MenuV2.Trigger
as={ButtonV2}
data-action="prompt-agent"
variant="ghost-muted"
size="normal"
class="max-w-[175px] justify-start ![font-weight:440]"
style={props.state.style}
>
<span class="truncate capitalize leading-5">{props.state.current}</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
<Icon name="chevron-down" size="small" />
</span>
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.RadioGroup value={props.state.current} onChange={props.state.onSelect}>
{props.state.options.map((value) => (
<MenuV2.RadioItem value={value} class="capitalize">
{value}
</MenuV2.RadioItem>
))}
</MenuV2.RadioGroup>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<Select
size="normal"
options={props.state.options}
current={props.state.current}
onSelect={props.state.onSelect}
class="max-w-[175px] justify-start text-v2-text-text-faint [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate pl-5 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
triggerStyle={props.state.style}
triggerProps={{ "data-action": "prompt-agent" }}
variant="ghost"
/>
</TooltipV2>
</div>
)
@@ -2370,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>
)
@@ -6,7 +6,7 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
const optimistic: Array<{
directory?: string
sessionID?: string
@@ -27,8 +27,6 @@ let params: { id?: string } = {}
let search: { draftId?: string } = {}
let selected = "/repo/worktree-a"
let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const prompt = {
@@ -58,7 +56,6 @@ const clientFor = (directory: string) => {
return {
session: {
create: async () => {
await createSessionGate
createdSessions.push(directory)
return {
data: {
@@ -125,14 +122,13 @@ beforeAll(async () => {
}),
}))
mock.module("@/context/permission", () => {
const state = (server: string) => ({
mock.module("@/context/permission", () => ({
usePermission: () => ({
enableAutoAccept(sessionID: string, directory: string) {
enabledAutoAccept.push({ server, sessionID, directory })
enabledAutoAccept.push({ sessionID, directory })
},
})
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
})
}),
}))
mock.module("@/context/server", () => ({
useServer: () => ({ key: "server-key" }),
@@ -255,8 +251,6 @@ beforeEach(() => {
syncedDirectories.length = 0
selected = "/repo/worktree-a"
variant = undefined
permissionServer = "server-a"
createSessionGate = undefined
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
})
@@ -324,40 +318,7 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event)
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
})
test("keeps auto-accept bound to the submission server", async () => {
let release = () => {}
createSessionGate = new Promise<void>((resolve) => {
release = resolve
})
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
permissionServer = "server-b"
release()
await result
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
})
test("promotes drafts using the selected project's server", async () => {
@@ -313,7 +313,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.resetHistoryNavigation()
const projectDirectory = sdk().directory
const permissionState = permission.currentServerState()
const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main"
@@ -377,7 +376,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
session = created
await startTransition(() => {
if (!session) return
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id, {
agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
@@ -1,13 +1,4 @@
import {
createEffect,
createSignal,
For,
onCleanup,
Show,
splitProps,
type Accessor,
type ComponentProps,
} from "solid-js"
import { createEffect, For, onCleanup, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
@@ -193,28 +184,9 @@ export function PromptProjectSelector(props: {
controller: PromptProjectController
placement?: "bottom" | "bottom-start"
}) {
const [triggerReady, setTriggerReady] = createSignal(false)
let contentRef: HTMLDivElement | undefined
let triggerFrame: number | undefined
let restoreTrigger = true
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
const setTriggerRef = (element: HTMLButtonElement) => {
const ready = () => {
if (!element.isConnected) {
triggerFrame = requestAnimationFrame(ready)
return
}
triggerFrame = undefined
setTriggerReady(true)
}
ready()
}
onCleanup(() => {
if (triggerFrame !== undefined) cancelAnimationFrame(triggerFrame)
})
const activeItem = () =>
props.controller.active()
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
@@ -285,13 +257,13 @@ export function PromptProjectSelector(props: {
return (
<DropdownMenu
open={triggerReady() && props.controller.open()}
open={props.controller.open()}
placement={props.placement ?? "bottom"}
gutter={4}
modal={false}
onOpenChange={(open) => props.controller.setOpen(open)}
>
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
<DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
<DropdownMenu.Portal>
<DropdownMenu.Content
ref={contentRef}
@@ -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,
}
}
@@ -7,7 +7,7 @@ export function NewSessionDesignView(props: { children: JSX.Element }) {
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
<div class="mt-8">{props.children}</div>
</div>
</div>
@@ -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>
)
}
@@ -65,12 +65,9 @@ export function SortableTerminalTabV2(props: {
const focus = () => {
if (store.editing) return
terminal.requestFocus(props.terminal.id)
terminal.open(props.terminal.id)
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
focusTerminalById(props.terminal.id)
const input = document.getElementById(`terminal-wrapper-${props.terminal.id}`)?.querySelector("textarea")
if (input === document.activeElement) terminal.consumeFocus(props.terminal.id)
}
const edit = (e?: Event) => {
@@ -5,7 +5,6 @@ import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useParams } from "@solidjs/router"
@@ -249,50 +248,6 @@ export const SettingsGeneral: Component = () => {
triggerVariant: "settings" as const,
})
const InterfaceSection = () => (
<div class="flex flex-col gap-1">
<SettingsList>
<SettingsRow
title={
<span class="flex items-center gap-2">
{language.t("settings.general.row.newInterface.title")}
<Tag variant="accent">{language.t("settings.general.row.newInterface.badge")}</Tag>
</span>
}
description={language.t("settings.general.row.newInterface.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (!checked) return
void import("@/components/settings-v2").then((module) => {
void dialog.show(() => <module.DialogSettings />)
})
}}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const InterfaceNoticeSection = () => (
<div class="flex flex-col gap-1">
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description")}
>
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
</Button>
</SettingsRow>
</SettingsList>
</div>
)
const GeneralSection = () => (
<div class="flex flex-col gap-1">
<SettingsList>
@@ -379,6 +334,24 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (!checked) return
void import("@/components/settings-v2").then((module) => {
dialog.show(() => <module.DialogSettings />)
})
}}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
@@ -745,14 +718,6 @@ export const SettingsGeneral: Component = () => {
</div>
<div class="flex flex-col gap-8 w-full">
<Show when={settings.general.layoutTransitionAvailable()}>
<InterfaceSection />
</Show>
<Show when={settings.general.newInterfaceNoticeVisible()}>
<InterfaceNoticeSection />
</Show>
<GeneralSection />
<AppearanceSection />
@@ -28,7 +28,6 @@ import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "../link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
import "./settings-v2.css"
let demoSoundState = {
@@ -227,31 +226,6 @@ export const SettingsGeneralV2: Component<{
},
})
const InterfaceSection = () => (
<LayoutTransitionToggle
title={language.t("settings.general.row.newInterface.title")}
badge={language.t("settings.general.row.newInterface.badge")}
description={language.t("settings.general.row.newInterface.description")}
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (checked) return
void import("@/components/dialog-settings").then((module) => {
void dialog.show(() => <module.DialogSettings />)
})
}}
/>
)
const InterfaceNoticeSection = () => (
<LayoutRetirementNotice
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description")}
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
onDismiss={settings.general.dismissNewInterfaceNotice}
/>
)
const GeneralSection = () => (
<div class="settings-v2-section">
<SettingsListV2>
@@ -338,6 +312,24 @@ export const SettingsGeneralV2: Component<{
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (checked) return
void import("@/components/dialog-settings").then((module) => {
dialog.show(() => <module.DialogSettings />)
})
}}
/>
</div>
</SettingsRowV2>
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<SettingsRowV2
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
@@ -691,14 +683,6 @@ export const SettingsGeneralV2: Component<{
</div>
<div class="settings-v2-tab-body">
<Show when={settings.general.layoutTransitionAvailable()}>
<InterfaceSection />
</Show>
<Show when={settings.general.newInterfaceNoticeVisible()}>
<InterfaceNoticeSection />
</Show>
<GeneralSection />
<AppearanceSection />
@@ -1,76 +0,0 @@
// @ts-nocheck
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
const copy = {
title: "New layout",
badge: "New",
description: "Use the new tabs and home layout. Switch between layouts for a limited time.",
noticeTitle: "You're now using new layout",
noticeDescription: "The previous layout is no longer available",
dismiss: "Dismiss",
}
function Frame(props) {
return <div class="w-[640px] max-w-full">{props.children}</div>
}
function ToggleExample(props) {
const [state, setState] = createStore({ checked: props.checked })
return (
<Frame>
<LayoutTransitionToggle
title={copy.title}
badge={copy.badge}
description={copy.description}
checked={state.checked}
onChange={(checked) => setState("checked", checked)}
/>
</Frame>
)
}
function NoticeExample() {
const [state, setState] = createStore({ dismissed: false })
return (
<Frame>
<Show when={!state.dismissed} fallback={<span class="text-v2-text-text-muted">Notice dismissed</span>}>
<LayoutRetirementNotice
title={copy.noticeTitle}
description={copy.noticeDescription}
dismiss={copy.dismiss}
onDismiss={() => setState("dismissed", true)}
/>
</Show>
</Frame>
)
}
export default {
title: "App/Settings/Layout transition",
id: "app-settings-layout-transition",
component: LayoutTransitionToggle,
}
export const NewLayoutEnabled = {
render: () => <ToggleExample checked />,
}
export const PreviousLayoutEnabled = {
render: () => <ToggleExample checked={false} />,
}
export const PreviousLayoutRetired = {
render: () => <NoticeExample />,
}
export const AllStates = {
render: () => (
<div class="flex flex-col gap-8">
<ToggleExample checked />
<ToggleExample checked={false} />
<NoticeExample />
</div>
),
}
@@ -1,54 +0,0 @@
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
export function LayoutTransitionToggle(props: {
title: string
badge: string
description: string
checked: boolean
onChange: (checked: boolean) => void
}) {
return (
<div class="settings-v2-section">
<div class="settings-v2-interface-feature">
<SettingsListV2>
<SettingsRowV2
title={
<span class="flex items-center gap-2">
{props.title}
<Tag variant="accent">{props.badge}</Tag>
</span>
}
description={props.description}
>
<div data-action="settings-new-layout-designs">
<Switch checked={props.checked} onChange={props.onChange} />
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
</div>
)
}
export function LayoutRetirementNotice(props: {
title: string
description: string
dismiss: string
onDismiss: () => void
}) {
return (
<div class="settings-v2-section">
<SettingsListV2>
<SettingsRowV2 title={props.title} description={props.description}>
<ButtonV2 size="small" variant="ghost-muted" onClick={props.onDismiss}>
{props.dismiss}
</ButtonV2>
</SettingsRowV2>
</SettingsListV2>
</div>
)
}
@@ -90,11 +90,6 @@
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
}
.settings-v2-interface-feature [data-component="settings-v2-list"] {
background-color: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-raised);
}
[data-component="settings-v2-row"] {
display: flex;
flex-wrap: wrap;
@@ -133,7 +128,7 @@
}
[data-slot="settings-v2-row-description"] {
font-size: 13px;
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
@@ -1,36 +0,0 @@
import { describe, expect, test } from "bun:test"
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
describe("serverStatusDotClass", () => {
test("uses the success token while the server and services are healthy", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
})
test("uses the warning token for non-blocking issues while the server is online", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
})
test("uses the critical token only after the server connection drops", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base")
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
})
test("stays neutral before status is ready", () => {
expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
})
})
describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
})
test("detects LSP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
})
})
@@ -1,19 +0,0 @@
import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
export function hasNonBlockingServiceIssue(input: {
mcp: Array<McpStatus["status"]>
lsp: Array<LspStatus["status"]>
}) {
return (
input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
input.lsp.some((status) => status === "error")
)
}
export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
if (input.serverHealth === false) return "bg-icon-critical-base"
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
if (input.issue) return "bg-icon-warning-base"
if (input.serverHealth === true) return "bg-icon-success-base"
return "bg-border-weak-base"
}
+43 -25
View File
@@ -9,7 +9,6 @@ import { ServerConnection, useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global"
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@@ -20,14 +19,16 @@ export function StatusPopover() {
const global = useGlobal()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
lsp: (sync().data.lsp ?? []).map((item) => item.status),
}),
)
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync().data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const serverHealthy = () => global.servers.health[server.key]?.healthy === true
const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
return (
<Popover
@@ -46,11 +47,13 @@ export function StatusPopover() {
<Icon name={shown() ? "status-active" : "status"} size="small" />
</div>
<div
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
ready: ready(),
serverHealth: serverHealth(),
issue: issue(),
})}`}
classList={{
"absolute -top-px -right-px size-1.5 rounded-full": true,
"bg-icon-success-base": ready() && healthy(),
"bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
"bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
"bg-border-weak-base": serverHealthy() || !ready(),
}}
/>
</div>
}
@@ -84,18 +87,21 @@ function DirectoryStatusPopover() {
const sync = useSync()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
lsp: (sync().data.lsp ?? []).map((item) => item.status),
}),
)
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync().data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: ready(),
healthy: healthy(),
serverHealth: serverHealth(),
issue: issue(),
issue: mcpIssue(),
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
@@ -117,8 +123,8 @@ function ServerStatusPopover() {
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: serverHealth() !== undefined,
healthy: serverHealth() === true,
serverHealth: serverHealth(),
issue: false,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
@@ -134,8 +140,9 @@ function ServerStatusPopover() {
type StatusPopoverState = {
shown: boolean
ready: boolean
healthy: boolean
serverHealth: boolean | undefined
issue: boolean
issue?: "critical" | "warning"
label: string
onOpenChange: (value: boolean) => void
body: () => JSX.Element
@@ -154,6 +161,16 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
}
function StatusPopoverView(props: { state: StatusPopoverState }) {
const statusDotClass = () => ({
"absolute rounded-full": true,
"bg-icon-success-base": props.state.ready && props.state.healthy,
"bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
"bg-icon-critical-base":
props.state.serverHealth === false ||
(props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
"bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
})
const popoverProps = {
class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
@@ -178,7 +195,8 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
<div class="relative size-4">
<IconV2 name={props.state.shown ? "status-active" : "status"} />
<div
class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
classList={statusDotClass()}
class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
/>
</div>
}
+2 -27
View File
@@ -23,7 +23,6 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
autoFocus?: boolean
onAutoFocus?: () => void
onSubmit?: () => void
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
onConnect?: () => void
@@ -186,15 +185,7 @@ export const Terminal = (props: TerminalProps) => {
const authToken = connection.type === "http" ? connection.authToken : false
const sameOrigin = new URL(url, location.href).origin === location.origin
let container!: HTMLDivElement
const [local, others] = splitProps(props, [
"pty",
"class",
"classList",
"autoFocus",
"onAutoFocus",
"onConnect",
"onConnectError",
])
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
@@ -425,7 +416,6 @@ export const Terminal = (props: TerminalProps) => {
fitAddon = fit
serializeAddon = serializer
const active = document.activeElement
t.open(container)
useTerminalUiBindings({
container,
@@ -435,22 +425,7 @@ export const Terminal = (props: TerminalProps) => {
handleLinkClick,
})
if (local.autoFocus === true) {
focusTerminal()
local.onAutoFocus?.()
}
if (local.autoFocus !== true) {
const restoreFocus = () => {
const current = document.activeElement
if (current !== container && !container.contains(current)) return
t.blur()
t.textarea?.blur()
if (active instanceof HTMLElement && active.isConnected) active.focus()
}
restoreFocus()
const timer = setTimeout(restoreFocus, 0)
cleanups.push(() => clearTimeout(timer))
}
if (local.autoFocus !== false) focusTerminal()
if (typeof document !== "undefined" && document.fonts) {
void document.fonts.ready.then(scheduleFit)
+4 -12
View File
@@ -47,20 +47,12 @@ export function getAvatarColors(key?: string) {
}
export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
if (key === "orange") return "orange"
if (key === "pink") return "pink"
if (key === "cyan") return "cyan"
if (key === "purple") return "purple"
if (key === "mint") return "cyan"
if (key === "lime") return "green"
if (
key === "orange" ||
key === "yellow" ||
key === "cyan" ||
key === "green" ||
key === "red" ||
key === "pink" ||
key === "blue" ||
key === "purple" ||
key === "gray"
)
return key
return "gray"
}
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
const session = (input: { id: string; parentID?: string }) =>
({
@@ -69,7 +69,6 @@ describe("autoRespondsPermission", () => {
}
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
expect(sessionAutoAccept(autoAccept, sessions, permission("root"), directory)).toBeUndefined()
})
test("session-level override takes precedence over directory-level", () => {
@@ -82,28 +81,6 @@ describe("autoRespondsPermission", () => {
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
})
test("parent false override takes precedence over directory-level auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
[`${base64Encode(directory)}/root`]: false,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
})
test("parent true override takes precedence over disabled directory fallback", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: false,
[`${base64Encode(directory)}/root`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
})
})
describe("isDirectoryAutoAccepting", () => {
@@ -11,7 +11,8 @@ export function directoryAcceptKey(directory: string) {
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
return autoAccept[key] ?? autoAccept[sessionID]
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
}
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
@@ -43,18 +44,8 @@ export function autoRespondsPermission(
permission: { sessionID: string },
directory?: string,
) {
const value = sessionAutoAccept(autoAccept, session, permission, directory)
if (value !== undefined) return value
return directory ? isDirectoryAutoAccepting(autoAccept, directory) : false
}
export function sessionAutoAccept(
autoAccept: Record<string, boolean>,
session: { id: string; parentID?: string }[],
permission: { sessionID: string },
directory?: string,
) {
return sessionLineage(session, permission.sessionID)
const value = sessionLineage(session, permission.sessionID)
.map((id) => accepted(autoAccept, id, directory))
.find((item): item is boolean => item !== undefined)
return value ?? false
}
+213 -403
View File
@@ -1,24 +1,17 @@
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { Persist, persisted } from "@/utils/persist"
import type { ServerSDK } from "@/context/server-sdk"
import type { ServerSync } from "./server-sync"
import { useParams, useSearchParams } from "@solidjs/router"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "./server-sync"
import { useParams } from "@solidjs/router"
import { decode64 } from "@/utils/base64"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { type DraftTab, useTabs } from "./tabs"
import { useSettings } from "./settings"
import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
import {
acceptKey,
directoryAcceptKey,
isDirectoryAutoAccepting,
autoRespondsPermission,
sessionAutoAccept,
} from "./permission-auto-respond"
type PermissionRespondFn = (input: {
@@ -54,417 +47,234 @@ function hasPermissionPromptRules(permission: unknown) {
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission",
gate: false,
init: () => {
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
const [search] = useSearchParams<{ draftId?: string }>()
const global = useGlobal()
const server = useServer()
const tabs = useTabs()
const settings = useSettings()
const owner = getOwner()
const states = new Map<ServerScope, { key: ServerConnection.Key; dispose: () => void; state: PermissionState }>()
const activeDraft = createMemo(() => {
if (!search.draftId) return
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
})
const activeServer = createMemo(() => {
if (params.serverKey && settings.general.newLayoutDesigns()) return requireServerKey(params.serverKey)
return activeDraft()?.server ?? server.key
})
const ensure = (key: ServerConnection.Key) => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
if (!conn) throw new Error(`Permission server not found: ${key}`)
const ctx = global.ensureServerCtx(conn)
const existing = states.get(ctx.sdk.scope)
if (existing && global.servers.list().some((item) => ServerConnection.key(item) === existing.key)) {
return existing.state
}
if (existing) {
existing.dispose()
states.delete(ctx.sdk.scope)
}
const root = createRoot(
(dispose) => ({
key,
dispose,
state: createServerPermissionState({ sdk: ctx.sdk, sync: ctx.sync }),
}),
owner ?? undefined,
)
states.set(ctx.sdk.scope, root)
return root.state
}
createEffect(() => {
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
})
createEffect(() => {
const list = global.servers.list()
const keys = new Set(list.map(ServerConnection.key))
states.forEach((value, scope) => {
if (keys.has(value.key)) return
value.dispose()
states.delete(scope)
const replacement = list.find((conn) => server.scope(ServerConnection.key(conn)) === scope)
if (replacement) ensure(ServerConnection.key(replacement))
})
})
onCleanup(() => states.forEach((value) => value.dispose()))
let lastSelected: PermissionState | undefined
const selected = () => {
const key = activeServer()
if (global.servers.list().some((conn) => ServerConnection.key(conn) === key)) {
lastSelected = ensure(key)
}
if (lastSelected) return lastSelected
return ensure(server.key)
}
const activeDirectory = createMemo(() => {
const directory = decode64(params.dir)
if (directory) return directory
const draft = activeDraft()
if (draft) return draft.directory
if (!params.id) return
if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
return selected().sync.session.lineage.peek(params.id)?.session.directory
})
createEffect(() => {
const directory = activeDirectory()
if (!directory) return
selected().enableConfiguredDirectory(directory)
})
init: (props: { directory?: Accessor<string | undefined> }) => {
const params = useParams()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const permissionsEnabled = createMemo(() => {
const directory = activeDirectory()
const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return false
return selected().permissionsEnabled(directory)
const [store] = serverSync().child(directory)
return hasPermissionPromptRules(store.config.permission)
})
const [store, setStore, _, ready] = persisted(
{
...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]),
migrate(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value
const data = value as Record<string, unknown>
if (data.autoAccept) return value
return {
...data,
autoAccept:
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
? data.autoAcceptEdits
: {},
}
},
},
createStore({
autoAccept: {} as Record<string, boolean>,
}),
)
// When config has permission: "allow", auto-enable directory-level auto-accept
createEffect(() => {
if (!ready()) return
const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return
const [childStore] = serverSync().child(directory)
const perm = childStore.config.permission
if (typeof perm === "string" && perm === "allow") {
const key = directoryAcceptKey(directory)
if (store.autoAccept[key] === undefined) {
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
}
}
})
const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000
const responded = new Map<string, number>()
const enableVersion = new Map<string, number>()
function pruneResponded(now: number) {
for (const [id, ts] of responded) {
if (now - ts < RESPONDED_TTL_MS) break
responded.delete(id)
}
for (const id of responded.keys()) {
if (responded.size <= MAX_RESPONDED) break
responded.delete(id)
}
}
const respond: PermissionRespondFn = (input) => {
serverSDK()
.client.permission.respond(input)
.catch(() => {
responded.delete(input.permissionID)
})
}
function respondOnce(permission: PermissionRequest, directory?: string) {
const now = Date.now()
const hit = responded.has(permission.id)
responded.delete(permission.id)
responded.set(permission.id, now)
pruneResponded(now)
if (hit) return
respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "once",
directory,
})
}
function isAutoAccepting(sessionID: string, directory?: string) {
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
}
function isAutoAcceptingDirectory(directory: string) {
return isDirectoryAutoAccepting(store.autoAccept, directory)
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, permission, directory)
}
function bumpEnableVersion(sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
const next = (enableVersion.get(key) ?? 0) + 1
enableVersion.set(key, next)
return next
}
const unsubscribe = serverSDK().event.listen((e) => {
const event = e.details
if (event?.type !== "permission.asked") return
const perm = event.properties
if (!shouldAutoRespond(perm, e.name)) return
respondOnce(perm, e.name)
})
onCleanup(unsubscribe)
function enableDirectory(directory: string) {
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
serverSDK()
.client.permission.list({ directory })
.then((x) => {
if (!isAutoAcceptingDirectory(directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue
respondOnce(perm, directory)
}
})
.catch(() => undefined)
}
function disableDirectory(directory: string) {
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = false
}),
)
}
function enable(sessionID: string, directory: string) {
const key = acceptKey(sessionID, directory)
const version = bumpEnableVersion(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
delete draft.autoAccept[sessionID]
}),
)
serverSDK()
.client.permission.list({ directory })
.then((x) => {
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue
respondOnce(perm, directory)
}
})
.catch(() => undefined)
}
function disable(sessionID: string, directory?: string) {
bumpEnableVersion(sessionID, directory)
const key = directory ? acceptKey(sessionID, directory) : sessionID
setStore(
produce((draft) => {
draft.autoAccept[key] = false
if (!directory) return
delete draft.autoAccept[sessionID]
}),
)
}
return {
ready: () => selected().ready(),
ensureServerState: (key: ServerConnection.Key) => ensure(key).api,
currentServerState: () => selected().api,
respond(input: Parameters<PermissionRespondFn>[0]) {
selected().respond(input)
},
ready,
respond,
autoResponds(permission: PermissionRequest, directory?: string) {
return selected().autoResponds(permission, directory)
},
isAutoAccepting(sessionID: string, directory?: string) {
return selected().isAutoAccepting(sessionID, directory)
},
isAutoAcceptingDirectory(directory: string) {
return selected().isAutoAcceptingDirectory(directory)
return shouldAutoRespond(permission, directory)
},
isAutoAccepting,
isAutoAcceptingDirectory,
toggleAutoAccept(sessionID: string, directory: string) {
selected().toggleAutoAccept(sessionID, directory)
if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory)
return
}
enable(sessionID, directory)
},
toggleAutoAcceptDirectory(directory: string) {
selected().toggleAutoAcceptDirectory(directory)
if (isAutoAcceptingDirectory(directory)) {
disableDirectory(directory)
return
}
enableDirectory(directory)
},
enableAutoAccept(sessionID: string, directory: string) {
selected().enableAutoAccept(sessionID, directory)
if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory)
},
disableAutoAccept(sessionID: string, directory?: string) {
selected().disableAutoAccept(sessionID, directory)
disable(sessionID, directory)
},
permissionsEnabled,
isPermissionAllowAll(directory: string) {
return selected().isPermissionAllowAll(directory)
const [childStore] = serverSync().child(directory)
const perm = childStore.config.permission
return typeof perm === "string" && perm === "allow"
},
}
},
})
type PermissionState = ReturnType<typeof createServerPermissionState>
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
const [store, setStore, _, ready] = persisted(
{
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
migrate(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value
const data = value as Record<string, unknown>
if (data.autoAccept) return value
return {
...data,
autoAccept:
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
? data.autoAcceptEdits
: {},
}
},
},
createStore({
autoAccept: {} as Record<string, boolean>,
}),
)
function enableConfiguredDirectory(directory: string) {
if (meta.disposed || !ready()) return
const [childStore] = input.sync.child(directory)
if (childStore.config.permission !== "allow") return
const key = directoryAcceptKey(directory)
if (store.autoAccept[key] !== undefined) return
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
}
const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000
const responded = new Map<string, number>()
const enableVersion = new Map<string, number>()
const meta = { disposed: false }
function pruneResponded(now: number) {
for (const [id, ts] of responded) {
if (now - ts < RESPONDED_TTL_MS) break
responded.delete(id)
}
for (const id of responded.keys()) {
if (responded.size <= MAX_RESPONDED) break
responded.delete(id)
}
}
const respond: PermissionRespondFn = (request) => {
if (meta.disposed) return
input.sdk.client.permission.respond(request).catch(() => {
responded.delete(request.permissionID)
})
}
function respondOnce(permission: PermissionRequest, directory?: string) {
const now = Date.now()
const hit = responded.has(permission.id)
responded.delete(permission.id)
responded.set(permission.id, now)
pruneResponded(now)
if (hit) return
respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "once",
directory,
})
}
function sessions(directory?: string) {
const info = Object.values(input.sync.session.data.info).filter((session) => !!session)
if (!directory) return info
return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session]
}
function isAutoAccepting(sessionID: string, directory?: string) {
return autoRespondsPermission(store.autoAccept, sessions(directory), { sessionID }, directory)
}
function isAutoAcceptingDirectory(directory: string) {
return isDirectoryAutoAccepting(store.autoAccept, directory)
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
return autoRespondsPermission(store.autoAccept, sessions(directory), permission, directory)
}
function isPending(permission: PermissionRequest) {
const pending = input.sync.session.data.permission[permission.sessionID]
return pending === undefined || pending.some((item) => item.id === permission.id)
}
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory)
if (override !== undefined) return override
if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined)
if (meta.disposed || !lineage) return false
return shouldAutoRespond(permission, directory)
}
async function respondPending(
permission: PermissionRequest,
directory?: string,
current: () => boolean = () => true,
) {
if (!current() || !isPending(permission)) return
if (!(await shouldAutoRespondResolved(permission, directory))) return
if (meta.disposed || !current() || !isPending(permission)) return
respondOnce(permission, directory)
}
function bumpEnableVersion(sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
const next = (enableVersion.get(key) ?? 0) + 1
enableVersion.set(key, next)
return next
}
const handlePermission = (e: PermissionEvent) => {
const event = e.details
if (event?.type !== "permission.asked") return
void respondPending(event.properties, e.name)
}
const unsubscribe = input.sdk.event.listen((event) => {
if (ready()) {
handlePermission(event)
return
}
void ready.promise?.then(() => {
if (meta.disposed) return
handlePermission(event)
})
})
onCleanup(() => {
meta.disposed = true
unsubscribe()
})
function enableDirectory(directory: string) {
if (meta.disposed) return
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
input.sdk.client.permission
.list({ directory })
.then((x) => {
if (meta.disposed) return
if (!isAutoAcceptingDirectory(directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory))
}
})
.catch(() => undefined)
}
function disableDirectory(directory: string) {
if (meta.disposed) return
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = false
}),
)
}
function enable(sessionID: string, directory: string) {
if (meta.disposed) return
const key = acceptKey(sessionID, directory)
const version = bumpEnableVersion(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
delete draft.autoAccept[sessionID]
}),
)
input.sdk.client.permission
.list({ directory })
.then((x) => {
if (meta.disposed) return
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
void respondPending(
perm,
directory,
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
)
}
})
.catch(() => undefined)
}
function disable(sessionID: string, directory?: string) {
if (meta.disposed) return
bumpEnableVersion(sessionID, directory)
const key = directory ? acceptKey(sessionID, directory) : sessionID
setStore(
produce((draft) => {
draft.autoAccept[key] = false
if (!directory) return
delete draft.autoAccept[sessionID]
}),
)
}
const api = {
ready: () => !meta.disposed && ready(),
respond,
autoResponds(permission: PermissionRequest, directory?: string) {
if (meta.disposed) return false
return shouldAutoRespond(permission, directory)
},
isAutoAccepting(sessionID: string, directory?: string) {
if (meta.disposed) return false
return isAutoAccepting(sessionID, directory)
},
isAutoAcceptingDirectory(directory: string) {
if (meta.disposed) return false
return isAutoAcceptingDirectory(directory)
},
toggleAutoAccept(sessionID: string, directory: string) {
if (meta.disposed) return
if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory)
return
}
enable(sessionID, directory)
},
toggleAutoAcceptDirectory(directory: string) {
if (meta.disposed) return
if (isAutoAcceptingDirectory(directory)) {
disableDirectory(directory)
return
}
enableDirectory(directory)
},
enableAutoAccept(sessionID: string, directory: string) {
if (meta.disposed) return
if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory)
},
disableAutoAccept(sessionID: string, directory?: string) {
if (meta.disposed) return
disable(sessionID, directory)
},
isPermissionAllowAll(directory: string) {
if (meta.disposed) return false
const [childStore] = input.sync.child(directory)
return childStore.config.permission === "allow"
},
}
return {
...api,
api,
sync: input.sync,
enableConfiguredDirectory,
permissionsEnabled(directory: string) {
if (meta.disposed) return false
const [childStore] = input.sync.child(directory)
return hasPermissionPromptRules(childStore.config.permission)
},
}
}
-3
View File
@@ -37,9 +37,6 @@ type PlatformBase = {
/** Open a local path in a local app (desktop only) */
openPath?(path: string, app?: string): Promise<void>
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
revealPath?(path: string): Promise<boolean>
/** Restart the app */
restart(): Promise<void>
+1 -1
View File
@@ -145,7 +145,7 @@ function contextItemKey(item: ContextItem) {
return `${key}:c=${digest.slice(0, 8)}`
}
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
-1
View File
@@ -24,7 +24,6 @@ export {
createPromptSession,
createPromptState,
DEFAULT_PROMPT,
isCommentItem,
isPromptEqual,
} from "./prompt-state"
export type {
@@ -173,7 +173,7 @@ describe("server session", () => {
await ctx.store.sync("root")
expect(ctx.get).toEqual([{ sessionID: "root" }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 2, before: undefined }])
expect(ctx.store.data.message.root).toEqual([])
})
@@ -193,7 +193,7 @@ describe("server session", () => {
await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }])
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(true)
+1 -1
View File
@@ -20,7 +20,7 @@ import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } fro
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 20
const initialMessagePageSize = 2
const historyMessagePageSize = 200
const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set()
-72
View File
@@ -1,72 +0,0 @@
import { describe, expect, test } from "bun:test"
import {
isAppUpgrade,
layoutTransitionState,
maximumSunsetTimeout,
newLayoutDesignsDefault,
nextSunsetCheckDelay,
resolveNewLayoutDesigns,
shouldDisplayTabsToast,
shouldEnableNewLayout,
} from "./settings"
describe("layout transition", () => {
test("blank profiles default to the new layout", () => {
expect(newLayoutDesignsDefault).toBe(true)
})
test("hides the transition until a sunset is scheduled", () => {
expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false })
})
test("existing profiles can switch before sunset", () => {
expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
})
test("preserves explicit and default layout preferences", () => {
expect(resolveNewLayoutDesigns(false, false, true)).toBe(false)
expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false)
expect(resolveNewLayoutDesigns(false, undefined, true)).toBe(true)
})
test("sunset replaces the toggle with a dismissible notice", () => {
expect(layoutTransitionState(true, true, true, false)).toEqual({ available: false, notice: true })
expect(layoutTransitionState(true, true, true, true)).toEqual({ available: false, notice: false })
expect(resolveNewLayoutDesigns(true, false)).toBe(true)
})
test("caps checks for sunsets beyond the browser timeout limit", () => {
expect(nextSunsetCheckDelay(maximumSunsetTimeout + 1_000, 0)).toBe(maximumSunsetTimeout)
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
})
test("enables the new layout when upgrading from 1.17.19 or earlier", () => {
expect(shouldEnableNewLayout("v1.17.19", "1.17.20")).toBe(true)
expect(shouldEnableNewLayout("1.16.9", "2.0.0")).toBe(true)
})
test("enables the new layout when no previous version was recorded", () => {
expect(shouldEnableNewLayout(undefined, "1.17.20")).toBe(true)
})
test("detects upgrades only when a previous version is older", () => {
expect(isAppUpgrade("1.17.19", "1.17.20")).toBe(true)
expect(isAppUpgrade(undefined, "1.17.20")).toBe(false)
expect(isAppUpgrade("1.17.20", "1.17.20")).toBe(false)
expect(isAppUpgrade("1.17.21", "1.17.20")).toBe(false)
})
test("shows the tabs toast for upgrades and existing installs without a recorded version", () => {
expect(shouldDisplayTabsToast("1.17.19", "1.17.20", false)).toBe(true)
expect(shouldDisplayTabsToast(undefined, "1.17.20", true)).toBe(true)
expect(shouldDisplayTabsToast(undefined, "1.17.20", false)).toBe(false)
})
test("does not enable the new layout without a qualifying upgrade", () => {
expect(shouldEnableNewLayout("1.17.19", "1.17.19")).toBe(false)
expect(shouldEnableNewLayout("1.17.20", "1.17.21")).toBe(false)
expect(shouldEnableNewLayout(undefined, "1.17.19")).toBe(false)
expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false)
})
})
+4 -177
View File
@@ -1,8 +1,7 @@
import { createStore, reconcile } from "solid-js/store"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createEffect, createMemo } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist"
import { usePlatform } from "@/context/platform"
export interface NotificationSettings {
agent: boolean
@@ -35,9 +34,6 @@ export interface Settings {
showCustomAgents: boolean
mobileTitlebarPosition: "top" | "bottom"
newLayoutDesigns?: boolean
layoutTransitionEligible?: boolean
newInterfaceNoticeDismissed?: boolean
shouldDisplayTabsToast?: boolean
}
appearance: {
fontSize: number
@@ -56,70 +52,7 @@ export interface Settings {
export const monoDefault = "System Mono"
export const sansDefault = "System Sans"
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export const newLayoutDesignsDefault = true
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
export const oldInterfaceSunset = new Date(2026, 8, 14)
const newLayoutDesignsUpgradeCutoff = "1.17.19"
function compareVersions(a: string, b: string) {
const parse = (version: string) => {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
if (!match) return
return match.slice(1).map(Number)
}
const left = parse(a)
const right = parse(b)
if (!left || !right) return
const index = left.findIndex((part, index) => part !== right[index])
return index === -1 ? 0 : left[index]! - right[index]!
}
export function isAppUpgrade(previous: string | undefined, current: string | undefined) {
if (!previous || !current) return false
const comparison = compareVersions(current, previous)
return comparison !== undefined && comparison > 0
}
export function shouldDisplayTabsToast(
previous: string | undefined,
current: string | undefined,
existingInstall: boolean,
) {
return isAppUpgrade(previous, current) || (!previous && existingInstall)
}
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
if (!current) return false
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
if (!previous) return currentComparison !== undefined && currentComparison > 0
if (!isAppUpgrade(previous, current)) return false
const previousComparison = compareVersions(previous, newLayoutDesignsUpgradeCutoff)
return (
previousComparison !== undefined &&
currentComparison !== undefined &&
previousComparison <= 0 &&
currentComparison > 0
)
}
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
return {
available: scheduled && eligible && !retired,
notice: scheduled && eligible && retired && !dismissed,
}
}
export const maximumSunsetTimeout = 2_147_483_647
export function nextSunsetCheckDelay(sunset: number, now: number) {
return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout)
}
export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) {
if (retired) return true
return preference ?? fallback
}
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
@@ -219,17 +152,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
name: "Settings",
gate: false,
init: () => {
const platform = usePlatform()
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const [launch, setLaunch, , launchReady] = persisted(
"app-version.v1",
createStore<{ version?: string }>({ version: undefined }),
)
const [launchState, setLaunchState] = createStore({
classified: false,
migrationApplied: false,
previous: undefined as string | undefined,
})
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
@@ -237,87 +160,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
() => store.general?.showCustomAgents,
defaultSettings.general.showCustomAgents,
)
const sunset = oldInterfaceSunset
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
const layoutUpgrade = createMemo(() =>
launchState.classified && !launchState.migrationApplied
? shouldEnableNewLayout(launchState.previous, platform.version)
: false,
)
const layoutTransition = createMemo(() =>
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
)
const newLayoutDesigns = createMemo(() => {
if (layoutUpgrade()) return true
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
if (!layoutTransitionClassified()) {
return resolveNewLayoutDesigns(
oldInterfaceRetired(),
store.general?.newLayoutDesigns,
legacyNewLayoutDesignsDefault,
)
}
return resolveNewLayoutDesigns(
oldInterfaceRetired(),
store.general?.newLayoutDesigns,
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
)
})
const newLayoutDesigns = withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault)
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
if (sunset && !oldInterfaceRetired()) {
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
const checkSunset = () => {
if (Date.now() >= sunset.getTime()) {
setOldInterfaceRetired(true)
return
}
timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now()))
}
checkSunset()
onCleanup(() => {
if (timeout.current !== undefined) clearTimeout(timeout.current)
})
}
createEffect(() => {
if (!launchReady() || launchState.classified) return
setLaunchState({
classified: true,
previous: launch.version,
})
if (!platform.version || launch.version === platform.version) return
setLaunch("version", platform.version)
})
createEffect(() => {
if (!ready() || !launchState.classified || launchState.migrationApplied) return
if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
setStore("general", "newLayoutDesigns", true)
}
setLaunchState("migrationApplied", true)
})
createEffect(() => {
if (!ready() || !launchState.classified) return
if (typeof store.general?.shouldDisplayTabsToast === "boolean") return
if (!launchState.previous && !layoutTransitionClassified()) return
setStore(
"general",
"shouldDisplayTabsToast",
shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()),
)
})
createEffect(() => {
if (!ready() || !oldInterfaceRetired()) return
if (store.general?.newLayoutDesigns === true) return
setStore("general", "newLayoutDesigns", true)
})
createEffect(() => {
if (typeof document === "undefined") return
const root = document.documentElement
@@ -405,25 +250,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
},
newLayoutDesigns,
setNewLayoutDesigns(value: boolean) {
const next = oldInterfaceRetired() ? true : value
if (newLayoutDesigns() === next) return
setStore("general", "newLayoutDesigns", next)
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
},
layoutTransitionClassified,
setOldLayoutEligible(eligible: boolean) {
const current = store.general?.layoutTransitionEligible
if (typeof current === "boolean") return
setStore("general", "layoutTransitionEligible", eligible)
},
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
dismissNewInterfaceNotice() {
setStore("general", "newInterfaceNoticeDismissed", true)
},
shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
dismissTabsToast() {
setStore("general", "shouldDisplayTabsToast", false)
setStore("general", "newLayoutDesigns", value)
},
},
visibility: {
+2 -3
View File
@@ -208,11 +208,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
return tab
},
async newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
const draftID = uuid()
const tab = { type: "draft" as const, draftID, ...draft }
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
await startTransition(() => {
void startTransition(() => {
setStore(
produce((tabs) => {
tabs.push(tab)
@@ -220,7 +220,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
navigate(draftHref(draftID))
})
return tab
},
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
void startTransition(() => {
+5 -68
View File
@@ -163,43 +163,6 @@ function createWorkspaceTerminalSession(
all: [],
}),
)
const [ui, setUi] = createStore({
focus: undefined as { request: number; id?: string; pending: boolean } | undefined,
})
const focus = { request: 0 }
const requestFocus = (id?: string, pending = false) => {
focus.request += 1
setUi("focus", { request: focus.request, id, pending })
return focus.request
}
const focusRequested = (id?: string) => {
if (!id) return false
if (!ui.focus || ui.focus.pending) return false
return !ui.focus.id || ui.focus.id === id
}
const consumeFocus = (id: string) => {
if (!focusRequested(id)) return
setUi("focus", undefined)
}
const cancelFocus = (request?: number) => {
if (request !== undefined && ui.focus?.request !== request) return
setUi("focus", undefined)
}
if (typeof document !== "undefined") {
const cancelOnOutsideFocus = (event: FocusEvent) => {
if (!ui.focus) return
if (!(event.target instanceof Element)) return
if (event.target.closest("#terminal-panel")) return
cancelFocus()
}
document.addEventListener("focusin", cancelOnOutsideFocus)
onCleanup(() => document.removeEventListener("focusin", cancelOnOutsideFocus))
}
const pickNextTerminalNumber = () => {
const existingTitleNumbers = new Set(
@@ -304,33 +267,23 @@ function createWorkspaceTerminalSession(
setStore("all", [])
})
},
new(options?: { focus?: boolean }) {
new() {
const nextNumber = pickNextTerminalNumber()
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
sdk.client.pty
.create({ title: defaultTitle(nextNumber) })
.then((pty: { data?: { id?: string; title?: string } }) => {
const id = pty.data?.id
if (!id) {
if (focusRequest !== undefined) cancelFocus(focusRequest)
return
}
if (!id) return
const newTerminal = {
id,
title: pty.data?.title ?? defaultTitle(nextNumber),
titleNumber: nextNumber,
}
batch(() => {
setStore("all", store.all.length, newTerminal)
setStore("active", id)
if (focusRequest !== undefined && ui.focus?.request === focusRequest) {
setUi("focus", { request: focusRequest, id, pending: false })
}
})
setStore("all", store.all.length, newTerminal)
setStore("active", id)
})
.catch((error: unknown) => {
if (focusRequest !== undefined) cancelFocus(focusRequest)
console.error("Failed to create terminal", error)
})
},
@@ -371,18 +324,6 @@ function createWorkspaceTerminalSession(
open(id: string) {
setStore("active", id)
},
requestFocus(id?: string) {
requestFocus(id)
},
focusRequested(id?: string) {
return focusRequested(id)
},
consumeFocus(id: string) {
consumeFocus(id)
},
cancelFocus() {
cancelFocus()
},
next() {
const index = store.all.findIndex((x) => x.id === store.active)
if (index === -1) return
@@ -501,17 +442,13 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
ready: () => workspace().ready(),
all: () => workspace().all(),
active: () => workspace().active(),
new: (options?: { focus?: boolean }) => workspace().new(options),
new: () => workspace().new(),
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
trim: (id: string) => workspace().trim(id),
trimAll: () => workspace().trimAll(),
clone: (id: string) => workspace().clone(id),
bind: () => workspace(),
open: (id: string) => workspace().open(id),
requestFocus: (id?: string) => workspace().requestFocus(id),
focusRequested: (id?: string) => workspace().focusRequested(id),
consumeFocus: (id: string) => workspace().consumeFocus(id),
cancelFocus: () => workspace().cancelFocus(),
close: (id: string) => workspace().close(id),
move: (id: string, to: number) => workspace().move(id, to),
next: () => workspace().next(),
+3 -7
View File
@@ -732,13 +732,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
"settings.general.row.editToolPartsExpanded.description":
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
"settings.general.row.newInterface.title": "التخطيط الجديد",
"settings.general.row.newInterface.badge": "جديد",
"settings.general.row.newInterface.description":
"استخدم علامات التبويب الجديدة وتخطيط الصفحة الرئيسية. يمكنك التبديل بين التخطيطات لفترة محدودة.",
"settings.general.row.newInterfaceNotice.title": "أنت تستخدم الآن التخطيط الجديد",
"settings.general.row.newInterfaceNotice.description": "التخطيط السابق لم يعد متاحًا",
"settings.general.row.newInterfaceNotice.dismiss": "رفض",
"settings.general.row.newLayoutDesigns.title": "التخطيط والتصاميم الجديدة",
"settings.general.row.newLayoutDesigns.description":
"تمكين التخطيط والصفحة الرئيسية ومحرر الرسائل وواجهة الجلسة المعاد تصميمها",
"settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص",
"settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير",
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
+3 -7
View File
@@ -743,13 +743,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
"settings.general.row.editToolPartsExpanded.description":
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
"settings.general.row.newInterface.title": "Novo layout",
"settings.general.row.newInterface.badge": "Novo",
"settings.general.row.newInterface.description":
"Use as novas abas e o layout da página inicial. Alterne entre os layouts por tempo limitado.",
"settings.general.row.newInterfaceNotice.title": "Agora você está usando o novo layout",
"settings.general.row.newInterfaceNotice.description": "O layout anterior não está mais disponível",
"settings.general.row.newInterfaceNotice.dismiss": "Descartar",
"settings.general.row.newLayoutDesigns.title": "Novo layout e design",
"settings.general.row.newLayoutDesigns.description":
"Ativar o layout, a página inicial, a área de composição e a interface de sessão reformulados",
"settings.general.row.pinchZoom.title": "Zoom com gesto de pinça",
"settings.general.row.pinchZoom.description":
"Permitir gestos de pinça no trackpad e de Ctrl+rolagem para aplicar zoom",
+3 -7
View File
@@ -808,13 +808,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
"settings.general.row.editToolPartsExpanded.description":
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
"settings.general.row.newInterface.title": "Novi raspored",
"settings.general.row.newInterface.badge": "Novo",
"settings.general.row.newInterface.description":
"Koristite nove kartice i raspored početne stranice. Ograničeno vrijeme možete se prebacivati između rasporeda.",
"settings.general.row.newInterfaceNotice.title": "Sada koristite novi raspored",
"settings.general.row.newInterfaceNotice.description": "Prethodni raspored više nije dostupan",
"settings.general.row.newInterfaceNotice.dismiss": "Odbaci",
"settings.general.row.newLayoutDesigns.title": "Novi raspored i dizajn",
"settings.general.row.newLayoutDesigns.description":
"Omogući redizajnirani raspored, početnu stranicu, uređivač poruke i interfejs sesije",
"settings.general.row.pinchZoom.title": "Zumiranje štipanjem",
"settings.general.row.pinchZoom.description":
"Dozvoli zumiranje gestom štipanja na dodirnoj ploči i pomoću Ctrl-pomjeranja",
+3 -7
View File
@@ -801,13 +801,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
"settings.general.row.editToolPartsExpanded.description":
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
"settings.general.row.newInterface.title": "Nyt layout",
"settings.general.row.newInterface.badge": "Ny",
"settings.general.row.newInterface.description":
"Brug de nye faner og startsidens layout. Du kan skifte mellem layoutene i en begrænset periode.",
"settings.general.row.newInterfaceNotice.title": "Du bruger nu det nye layout",
"settings.general.row.newInterfaceNotice.description": "Det tidligere layout er ikke længere tilgængeligt",
"settings.general.row.newInterfaceNotice.dismiss": "Afvis",
"settings.general.row.newLayoutDesigns.title": "Nyt layout og design",
"settings.general.row.newLayoutDesigns.description":
"Aktivér det nydesignede layout, startsiden, promptfeltet og sessionsbrugerfladen",
"settings.general.row.pinchZoom.title": "Knib for at zoome",
"settings.general.row.pinchZoom.description": "Tillad knibebevægelser på pegefeltet og Ctrl-rulning for at zoome",
"settings.general.row.wayland.title": "Brug native Wayland",
+3 -7
View File
@@ -756,13 +756,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
"settings.general.row.editToolPartsExpanded.description":
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
"settings.general.row.newInterface.title": "Neues Layout",
"settings.general.row.newInterface.badge": "Neu",
"settings.general.row.newInterface.description":
"Verwenden Sie die neuen Tabs und das Startseitenlayout. Für begrenzte Zeit können Sie zwischen den Layouts wechseln.",
"settings.general.row.newInterfaceNotice.title": "Sie verwenden jetzt das neue Layout",
"settings.general.row.newInterfaceNotice.description": "Das vorherige Layout ist nicht mehr verfügbar",
"settings.general.row.newInterfaceNotice.dismiss": "Verwerfen",
"settings.general.row.newLayoutDesigns.title": "Neues Layout und Design",
"settings.general.row.newLayoutDesigns.description":
"Neu gestaltetes Layout, Startseite, Eingabebereich und Sitzungsoberfläche aktivieren",
"settings.general.row.pinchZoom.title": "Zoom per Fingergeste",
"settings.general.row.pinchZoom.description": "Zoomen per Zwei-Finger- und Ctrl-Scroll-Geste erlauben",
"settings.general.row.wayland.title": "Natives Wayland verwenden",
+8 -13
View File
@@ -638,7 +638,7 @@ export const dict = {
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab",
"session.error.serverConnection": "Can't connect to this server",
"session.review.filesChanged": "Files Changed {{count}}",
"session.review.filesChanged": "{{count}} Files Changed",
"session.review.change.one": "Change",
"session.review.change.other": "Changes",
"session.review.loadingChanges": "Loading changes...",
@@ -851,8 +851,9 @@ export const dict = {
"settings.general.row.language.title": "Language",
"settings.general.row.language.description": "Change the display language for OpenCode",
"settings.general.row.shell.title": "Terminal shell",
"settings.general.row.shell.description": "Shell used by the terminal and agent tools",
"settings.general.row.shell.title": "Terminal Shell",
"settings.general.row.shell.description":
"Choose the shell used for your terminal. Compatible shells are also used for agent tool calls.",
"settings.general.row.shell.autoDefault": "Auto (Default)",
"settings.general.row.shell.terminalOnly": "terminal only",
"settings.general.row.appearance.title": "Appearance",
@@ -884,9 +885,8 @@ export const dict = {
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
"settings.general.row.mobileTitlebarBottom.description":
"Place the title bar and session tabs at the bottom of the screen on mobile",
"settings.general.row.showCustomAgents.title": "Show agent",
"settings.general.row.showCustomAgents.description":
"Switch between agents in the composer. When hidden, defaults to Build agent.",
"settings.general.row.showCustomAgents.title": "Custom agents",
"settings.general.row.showCustomAgents.description": "Show the agent picker in the composer",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
@@ -895,13 +895,8 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
"settings.general.row.editToolPartsExpanded.description":
"Show edit, write, and patch tool parts expanded by default in the timeline",
"settings.general.row.newInterface.title": "New layout",
"settings.general.row.newInterface.badge": "New",
"settings.general.row.newInterface.description":
"Use the new tabs and home layout. Switch between layouts for a limited time.",
"settings.general.row.newInterfaceNotice.title": "You're now using new layout",
"settings.general.row.newInterfaceNotice.description": "The previous layout is no longer available",
"settings.general.row.newInterfaceNotice.dismiss": "Dismiss",
"settings.general.row.newLayoutDesigns.title": "New layout and designs",
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
"settings.general.row.pinchZoom.title": "Pinch to zoom",
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
+3 -7
View File
@@ -813,13 +813,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
"settings.general.row.editToolPartsExpanded.description":
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
"settings.general.row.newInterface.title": "Nuevo diseño",
"settings.general.row.newInterface.badge": "Nuevo",
"settings.general.row.newInterface.description":
"Usa las nuevas pestañas y el diseño de la página de inicio. Durante un tiempo limitado, puedes cambiar entre los diseños.",
"settings.general.row.newInterfaceNotice.title": "Ahora estás usando el nuevo diseño",
"settings.general.row.newInterfaceNotice.description": "El diseño anterior ya no está disponible",
"settings.general.row.newInterfaceNotice.dismiss": "Descartar",
"settings.general.row.newLayoutDesigns.title": "Nuevo diseño y estilos",
"settings.general.row.newLayoutDesigns.description":
"Activar el nuevo diseño de la interfaz, la página de inicio, el editor y las sesiones",
"settings.general.row.pinchZoom.title": "Pellizcar para ampliar",
"settings.general.row.pinchZoom.description":
"Permitir ampliar con el gesto de pellizco del panel táctil y con Ctrl-scroll",
+3 -7
View File
@@ -753,13 +753,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
"settings.general.row.editToolPartsExpanded.description":
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
"settings.general.row.newInterface.title": "Nouvelle mise en page",
"settings.general.row.newInterface.badge": "Nouveau",
"settings.general.row.newInterface.description":
"Utilisez les nouveaux onglets et la mise en page de l'accueil. Vous pouvez alterner entre les mises en page pendant une durée limitée.",
"settings.general.row.newInterfaceNotice.title": "Vous utilisez maintenant la nouvelle mise en page",
"settings.general.row.newInterfaceNotice.description": "La mise en page précédente n'est plus disponible",
"settings.general.row.newInterfaceNotice.dismiss": "Ignorer",
"settings.general.row.newLayoutDesigns.title": "Nouvelle mise en page et nouveaux designs",
"settings.general.row.newLayoutDesigns.description":
"Activer la nouvelle mise en page et les nouvelles interfaces d'accueil, de saisie et de session",
"settings.general.row.pinchZoom.title": "Pincer pour zoomer",
"settings.general.row.pinchZoom.description":
"Autoriser les gestes de pincement du pavé tactile et Ctrl-défilement pour zoomer",
+3 -7
View File
@@ -738,13 +738,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
"settings.general.row.editToolPartsExpanded.description":
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
"settings.general.row.newInterface.title": "新しいレイアウト",
"settings.general.row.newInterface.badge": "新機能",
"settings.general.row.newInterface.description":
"新しいタブとホーム画面のレイアウトを使用します。期間限定でレイアウトを切り替えられます。",
"settings.general.row.newInterfaceNotice.title": "新しいレイアウトを使用しています",
"settings.general.row.newInterfaceNotice.description": "以前のレイアウトは利用できなくなりました",
"settings.general.row.newInterfaceNotice.dismiss": "閉じる",
"settings.general.row.newLayoutDesigns.title": "新しいレイアウトとデザイン",
"settings.general.row.newLayoutDesigns.description":
"刷新されたレイアウト、ホーム、コンポーザー、セッションUIを有効にします",
"settings.general.row.pinchZoom.title": "ピンチでズーム",
"settings.general.row.pinchZoom.description":
"トラックパッドのピンチ操作とCtrl+スクロール操作によるズームを許可します",
+2 -7
View File
@@ -993,13 +993,8 @@ export const dict = {
"settings.general.row.mobileTitlebarBottom.description": "모바일에서 제목 표시줄과 세션 탭을 화면 하단에 배치",
"settings.general.row.showCustomAgents.title": "사용자 지정 에이전트",
"settings.general.row.showCustomAgents.description": "입력창에 에이전트 선택기 표시",
"settings.general.row.newInterface.title": "새 레이아웃",
"settings.general.row.newInterface.badge": "신규",
"settings.general.row.newInterface.description":
"새 탭과 홈 화면 레이아웃을 사용합니다. 제한된 기간 동안 레이아웃을 전환할 수 있습니다.",
"settings.general.row.newInterfaceNotice.title": "이제 새 레이아웃을 사용 중입니다",
"settings.general.row.newInterfaceNotice.description": "이전 레이아웃은 더 이상 사용할 수 없습니다",
"settings.general.row.newInterfaceNotice.dismiss": "닫기",
"settings.general.row.newLayoutDesigns.title": "새 레이아웃 및 디자인",
"settings.general.row.newLayoutDesigns.description": "새롭게 디자인된 레이아웃, 홈, 입력창 및 세션 UI 활성화",
"settings.general.row.pinchZoom.title": "핀치 줌",
"settings.general.row.pinchZoom.description": "트랙패드 핀치 및 Ctrl-스크롤 제스처로 확대/축소 허용",
"settings.updates.action.downloading": "다운로드 중...",
+3 -7
View File
@@ -1089,13 +1089,9 @@ export const dict = {
"Plasser tittellinjen og sesjonsfanene nederst på mobilskjermen",
"settings.general.row.showCustomAgents.title": "Egendefinerte agenter",
"settings.general.row.showCustomAgents.description": "Vis agentvelgeren i skrivefeltet",
"settings.general.row.newInterface.title": "Nytt oppsett",
"settings.general.row.newInterface.badge": "Ny",
"settings.general.row.newInterface.description":
"Bruk de nye fanene og startsidens oppsett. I en begrenset periode kan du bytte mellom oppsettene.",
"settings.general.row.newInterfaceNotice.title": "Du bruker nå det nye oppsettet",
"settings.general.row.newInterfaceNotice.description": "Det forrige oppsettet er ikke lenger tilgjengelig",
"settings.general.row.newInterfaceNotice.dismiss": "Avvis",
"settings.general.row.newLayoutDesigns.title": "Ny layout og utforming",
"settings.general.row.newLayoutDesigns.description":
"Aktiver ny layout og utforming for startsiden, skrivefeltet og sesjonsgrensesnittet",
"settings.general.row.pinchZoom.title": "Knip for å zoome",
"settings.general.row.pinchZoom.description": "Tillat knipebevegelser på styreflaten og Ctrl-rulling for å zoome",
"settings.updates.action.downloading": "Laster ned...",
+3 -7
View File
@@ -743,13 +743,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
"settings.general.row.editToolPartsExpanded.description":
"Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
"settings.general.row.newInterface.title": "Nowy układ",
"settings.general.row.newInterface.badge": "Nowość",
"settings.general.row.newInterface.description":
"Używaj nowych kart i układu strony głównej. Przez ograniczony czas możesz przełączać się między układami.",
"settings.general.row.newInterfaceNotice.title": "Korzystasz teraz z nowego układu",
"settings.general.row.newInterfaceNotice.description": "Poprzedni układ nie jest już dostępny",
"settings.general.row.newInterfaceNotice.dismiss": "Odrzuć",
"settings.general.row.newLayoutDesigns.title": "Nowy układ i wygląd",
"settings.general.row.newLayoutDesigns.description":
"Włącz przeprojektowany układ, stronę główną, edytor wiadomości i interfejs sesji",
"settings.general.row.pinchZoom.title": "Powiększanie gestem szczypania",
"settings.general.row.pinchZoom.description":
"Zezwalaj na powiększanie gestem szczypania na gładziku i przewijaniem z klawiszem Ctrl",
+3 -7
View File
@@ -809,13 +809,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
"settings.general.row.editToolPartsExpanded.description":
"Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
"settings.general.row.newInterface.title": "Новая компоновка",
"settings.general.row.newInterface.badge": "Новое",
"settings.general.row.newInterface.description":
"Используйте новые вкладки и компоновку главной страницы. В течение ограниченного времени можно переключаться между вариантами компоновки.",
"settings.general.row.newInterfaceNotice.title": "Теперь вы используете новую компоновку",
"settings.general.row.newInterfaceNotice.description": "Прежняя компоновка больше недоступна",
"settings.general.row.newInterfaceNotice.dismiss": "Закрыть",
"settings.general.row.newLayoutDesigns.title": "Новая компоновка и оформление",
"settings.general.row.newLayoutDesigns.description":
"Включить обновлённую компоновку, главную страницу, редактор запросов и интерфейс сессии",
"settings.general.row.pinchZoom.title": "Масштабирование щипком",
"settings.general.row.pinchZoom.description":
"Разрешить масштабирование жестом щипка на трекпаде и прокруткой с Ctrl",
+3 -7
View File
@@ -798,13 +798,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
"settings.general.row.editToolPartsExpanded.description":
"แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
"settings.general.row.newInterface.title": "เลย์เอาต์ใหม่",
"settings.general.row.newInterface.badge": "ใหม่",
"settings.general.row.newInterface.description":
"ใช้แท็บใหม่และเลย์เอาต์หน้าแรก คุณสามารถสลับระหว่างเลย์เอาต์ได้ในช่วงเวลาจำกัด",
"settings.general.row.newInterfaceNotice.title": "ขณะนี้คุณกำลังใช้เลย์เอาต์ใหม่",
"settings.general.row.newInterfaceNotice.description": "เลย์เอาต์ก่อนหน้าไม่พร้อมใช้งานอีกต่อไป",
"settings.general.row.newInterfaceNotice.dismiss": "ปิด",
"settings.general.row.newLayoutDesigns.title": "เลย์เอาต์และดีไซน์ใหม่",
"settings.general.row.newLayoutDesigns.description":
"เปิดใช้เลย์เอาต์ หน้าแรก ช่องเขียนข้อความ และ UI เซสชันที่ออกแบบใหม่",
"settings.general.row.pinchZoom.title": "บีบนิ้วเพื่อซูม",
"settings.general.row.pinchZoom.description": "อนุญาตให้ใช้ท่าบีบนิ้วบนแทร็คแพดและ Ctrl-scroll เพื่อซูม",
"settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",
+3 -7
View File
@@ -813,13 +813,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet",
"settings.general.row.editToolPartsExpanded.description":
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
"settings.general.row.newInterface.title": "Yeni düzen",
"settings.general.row.newInterface.badge": "Yeni",
"settings.general.row.newInterface.description":
"Yeni sekmeleri ve ana sayfa düzenini kullanın. Sınırlı bir süre boyunca düzenler arasında geçiş yapabilirsiniz.",
"settings.general.row.newInterfaceNotice.title": "Artık yeni düzeni kullanıyorsunuz",
"settings.general.row.newInterfaceNotice.description": "Önceki düzen artık kullanılamıyor",
"settings.general.row.newInterfaceNotice.dismiss": "Kapat",
"settings.general.row.newLayoutDesigns.title": "Yeni düzen ve tasarımlar",
"settings.general.row.newLayoutDesigns.description":
"Yeniden tasarlanan düzeni, ana sayfayı, yazma alanını ve oturum arayüzünü etkinleştir",
"settings.general.row.pinchZoom.title": "Sıkıştırarak yakınlaştır",
"settings.general.row.pinchZoom.description":
"İzleme dörtgeninde sıkıştırma ve Ctrl-kaydırma hareketleriyle yakınlaştırmaya izin ver",
+3 -7
View File
@@ -898,13 +898,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування",
"settings.general.row.editToolPartsExpanded.description":
"Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі",
"settings.general.row.newInterface.title": "Новий макет",
"settings.general.row.newInterface.badge": "Нове",
"settings.general.row.newInterface.description":
"Використовуйте нові вкладки та макет головної сторінки. Протягом обмеженого часу можна перемикатися між макетами.",
"settings.general.row.newInterfaceNotice.title": "Тепер ви використовуєте новий макет",
"settings.general.row.newInterfaceNotice.description": "Попередній макет більше недоступний",
"settings.general.row.newInterfaceNotice.dismiss": "Відхилити",
"settings.general.row.newLayoutDesigns.title": "Новий макет і дизайн",
"settings.general.row.newLayoutDesigns.description":
"Увімкнути оновлений макет та інтерфейси головної сторінки, редактора запитів і сесії",
"settings.general.row.pinchZoom.title": "Масштабування щипком",
"settings.general.row.pinchZoom.description":
"Дозволити масштабування жестом щипка на трекпаді та прокручуванням із Ctrl",
+2 -6
View File
@@ -793,12 +793,8 @@ export const dict = {
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
"settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分",
"settings.general.row.newInterface.title": "新布局",
"settings.general.row.newInterface.badge": "新",
"settings.general.row.newInterface.description": "使用新的标签页和主页布局。在有限时间内可在不同布局之间切换。",
"settings.general.row.newInterfaceNotice.title": "你现在使用的是新布局",
"settings.general.row.newInterfaceNotice.description": "之前的布局不再可用",
"settings.general.row.newInterfaceNotice.dismiss": "忽略",
"settings.general.row.newLayoutDesigns.title": "新布局和设计",
"settings.general.row.newLayoutDesigns.description": "启用重新设计的布局、主页、输入框和会话界面",
"settings.general.row.pinchZoom.title": "双指缩放",
"settings.general.row.pinchZoom.description": "允许使用触控板双指捏合和 Ctrl-scroll 手势缩放",
"settings.general.row.wayland.title": "使用原生 Wayland",
+2 -6
View File
@@ -789,12 +789,8 @@ export const dict = {
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
"settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊",
"settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊",
"settings.general.row.newInterface.title": "新版面",
"settings.general.row.newInterface.badge": "新",
"settings.general.row.newInterface.description": "使用新的分頁和首頁版面。在限定時間內可切換版面。",
"settings.general.row.newInterfaceNotice.title": "你現在使用的是新版面",
"settings.general.row.newInterfaceNotice.description": "先前的版面已無法使用",
"settings.general.row.newInterfaceNotice.dismiss": "忽略",
"settings.general.row.newLayoutDesigns.title": "新版面與設計",
"settings.general.row.newLayoutDesigns.description": "啟用重新設計的版面、首頁、輸入區和工作階段介面",
"settings.general.row.pinchZoom.title": "雙指縮放",
"settings.general.row.pinchZoom.description": "允許使用觸控板雙指開合和 Ctrl-捲動手勢縮放",
"settings.general.row.wayland.title": "使用原生 Wayland",
-1
View File
@@ -3,7 +3,6 @@ export { useLayout } from "./context/layout"
export { useServerSDK } from "./context/server-sdk"
export { useServerSync } from "./context/server-sync"
export { useServer } from "./context/server"
export { useSettings } from "./context/settings"
export { useTabs } from "./context/tabs"
export { useProviders } from "./hooks/use-providers"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
@@ -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
+3 -4
View File
@@ -35,7 +35,7 @@ import { usePlatform } from "@/context/platform"
import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useDirectoryPicker } from "@/components/directory-picker"
import { useSettingsCommand } from "@/components/settings-dialog"
import { useSettingsDialog } from "@/components/settings-dialog"
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { ServerConnection, serverName, useServer } from "@/context/server"
@@ -244,7 +244,6 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
// 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,
@@ -269,7 +268,7 @@ export function NewHome() {
const command = useCommand()
const notification = useNotification()
const marked = useMarked()
const openSettings = useSettingsCommand()
const openSettings = useSettingsDialog()
let focusSessionSearch: (() => void) | undefined
const [state, setState] = createStore({
search: "",
@@ -795,7 +794,7 @@ function HomeUtilityNav(props: {
language: ReturnType<typeof useLanguage>
}) {
return (
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1`}>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
+2
View File
@@ -6,11 +6,13 @@ import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast"
import { useSettingsCommand } from "@/components/settings-dialog"
export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const navigate = useNavigate()
setNavigate(navigate)
useSettingsCommand()
createEffect(() => setV2Toast(true))
@@ -13,7 +13,6 @@ export function useSessionTabAvatarState(
const global = useGlobal()
const notification = useNotification()
const permission = usePermission()
const permissionState = createMemo(() => permission.ensureServerState(server()))
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
const sync = createMemo(() => {
const conn = connection()
@@ -24,7 +23,7 @@ export function useSessionTabAvatarState(
if (!serverSync) return false
const [store] = serverSync.child(directory(), { bootstrap: false })
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
return !permissionState().autoResponds(item, directory())
return !permission.autoResponds(item, directory())
})
})
const hasQuestions = createMemo(() => {
+1 -2
View File
@@ -28,7 +28,7 @@ import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
import { useTitlebarRightMount } from "@/components/titlebar"
import { useCommand } from "@/context/command"
import { useProviders } from "@/hooks/use-providers"
import { useSettingsCommand, useSettingsDialog } from "@/components/settings-dialog"
import { useSettingsDialog } from "@/components/settings-dialog"
import { Persist, persisted } from "@/utils/persist"
import createPresence from "solid-presence"
import { useLocal } from "@/context/local"
@@ -54,7 +54,6 @@ export default function NewSessionPage() {
const command = useCommand()
const providers = useProviders(() => sdk().directory)
const openProviderSettings = useSettingsDialog("providers")
useSettingsCommand()
const route = useSessionKey()
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
const local = useLocal()

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