Compare commits

..

13 Commits

Author SHA1 Message Date
Brendan Allan 1140d8422e chore(app): schedule layout sunset outside prod 2026-07-14 14:36:34 +00:00
Brendan Allan 9ecfb847fa fix(desktop): derive layout eligibility from install state 2026-07-14 14:06:26 +00:00
usrnk1 588288ce87 test(app): add layout transition stories 2026-07-13 21:36:41 +02:00
usrnk1 0156d3028f feat(app): simplify layout transition messaging 2026-07-13 21:36:08 +02:00
usrnk1 5e5e305bb7 fix(app): avoid layout flash during cohort detection 2026-07-13 14:40:32 +02:00
usrnk1 d87f987800 feat(app): preserve previous layout for active users 2026-07-13 14:37:01 +02:00
usrnk1 545b86587d feat(app): detect prior server activity for layout transition 2026-07-13 14:36:34 +02:00
usrnk1 7fb3fbdf27 feat(app): clarify layout transition timing 2026-07-13 13:35:26 +02:00
usrnk1 31f946eed7 feat(app): enforce layout sunset for existing users 2026-07-13 13:35:08 +02:00
usrnk1 45daa7fece feat(app): localize interface retirement notice 2026-07-13 13:08:13 +02:00
usrnk1 a5bbe636e7 feat(app): clarify interface transition and retirement 2026-07-13 13:06:17 +02:00
usrnk1 2e1ce87bf3 feat(app): add interface transition setting 2026-07-13 13:05:46 +02:00
usrnk1 56e8a4f7d7 feat(ui): add accent badge variant 2026-07-13 13:05:46 +02:00
339 changed files with 4008 additions and 12206 deletions
+1 -1
View File
@@ -18,4 +18,4 @@ simonklee
Slickstef11 Slickstef11
usrnk1 usrnk1
vimtor vimtor
StarpTech starptech
+1 -1
View File
@@ -1,6 +1,6 @@
--- ---
description: translate English to other languages description: translate English to other languages
model: opencode/gpt-5.6-sol model: opencode/claude-opus-4-8
--- ---
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time. run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
+68 -103
View File
@@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@corvu/drawer": "catalog:", "@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0", "@dnd-kit/abstract": "0.5.0",
@@ -65,7 +65,7 @@
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", "ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
@@ -95,7 +95,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@opencode-ai/cli", "name": "@opencode-ai/cli",
"version": "1.18.4", "version": "1.17.18",
"bin": { "bin": {
"lildax": "./bin/lildax.cjs", "lildax": "./bin/lildax.cjs",
}, },
@@ -143,7 +143,7 @@
}, },
"packages/codemode": { "packages/codemode": {
"name": "@opencode-ai/codemode", "name": "@opencode-ai/codemode",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"acorn": "8.15.0", "acorn": "8.15.0",
"effect": "catalog:", "effect": "catalog:",
@@ -157,7 +157,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -193,7 +193,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -220,7 +220,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.82", "@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -242,7 +242,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -266,7 +266,7 @@
}, },
"packages/console/support": { "packages/console/support": {
"name": "@opencode-ai/console-support", "name": "@opencode-ai/console-support",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
@@ -286,7 +286,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.18.4", "version": "1.17.18",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -294,7 +294,7 @@
"@ai-sdk/alibaba": "1.0.17", "@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82", "@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/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
@@ -303,7 +303,7 @@
"@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@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/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
@@ -338,7 +338,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.1", "gitlab-ai-provider": "6.10.0",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -380,7 +380,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"effect": "catalog:", "effect": "catalog:",
@@ -434,7 +434,7 @@
}, },
"packages/effect-drizzle-sqlite": { "packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite", "name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -448,7 +448,7 @@
}, },
"packages/effect-sqlite-node": { "packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node", "name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"effect": "catalog:", "effect": "catalog:",
}, },
@@ -460,7 +460,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@hono/standard-validator": "catalog:", "@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -492,7 +492,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -508,7 +508,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83",
@@ -539,7 +539,7 @@
}, },
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
@@ -558,7 +558,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.18.4", "version": "1.17.18",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -569,7 +569,7 @@
"@ai-sdk/alibaba": "1.0.17", "@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82", "@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/cerebras": "2.0.60",
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
@@ -578,7 +578,7 @@
"@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@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/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
@@ -631,7 +631,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.1", "gitlab-ai-provider": "6.10.0",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -689,7 +689,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -706,9 +706,9 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.4.5", "@opentui/core": ">=0.4.3",
"@opentui/keymap": ">=0.4.5", "@opentui/keymap": ">=0.4.3",
"@opentui/solid": ">=0.4.5", "@opentui/solid": ">=0.4.3",
}, },
"optionalPeers": [ "optionalPeers": [
"@opentui/core", "@opentui/core",
@@ -765,7 +765,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -780,7 +780,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@opencode-ai/server", "name": "@opencode-ai/server",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
@@ -795,7 +795,7 @@
}, },
"packages/session-ui": { "packages/session-ui": {
"name": "@opencode-ai/session-ui", "name": "@opencode-ai/session-ui",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -839,7 +839,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -852,10 +852,9 @@
}, },
"packages/stats/app": { "packages/stats/app": {
"name": "@opencode-ai/stats-app", "name": "@opencode-ai/stats-app",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/stats-core": "workspace:*", "@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
@@ -886,7 +885,7 @@
}, },
"packages/stats/core": { "packages/stats/core": {
"name": "@opencode-ai/stats-core", "name": "@opencode-ai/stats-core",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@aws-sdk/client-athena": "3.933.0", "@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0", "@planetscale/database": "1.19.0",
@@ -905,7 +904,7 @@
}, },
"packages/stats/server": { "packages/stats/server": {
"name": "@opencode-ai/stats-server", "name": "@opencode-ai/stats-server",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@aws-sdk/client-firehose": "3.933.0", "@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
@@ -937,7 +936,6 @@
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/react": "18.0.25", "@types/react": "18.0.25",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0",
"solid-js": "catalog:", "solid-js": "catalog:",
"storybook": "^10.2.13", "storybook": "^10.2.13",
"storybook-solidjs-vite": "^10.0.9", "storybook-solidjs-vite": "^10.0.9",
@@ -947,7 +945,7 @@
}, },
"packages/tui": { "packages/tui": {
"name": "@opencode-ai/tui", "name": "@opencode-ai/tui",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
@@ -974,7 +972,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
@@ -1025,7 +1023,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.18.4", "version": "1.17.18",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -1068,7 +1066,6 @@
], ],
"patchedDependencies": { "patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@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", "@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", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
@@ -1076,8 +1073,10 @@
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "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", "@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", "@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", "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", "@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", "pacote@21.5.0": "patches/pacote@21.5.0.patch",
}, },
"overrides": { "overrides": {
@@ -1100,9 +1099,9 @@
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.4.5", "@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.5", "@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.5", "@opentui/solid": "0.4.3",
"@pierre/diffs": "1.2.10", "@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0", "@sentry/solid": "10.36.0",
@@ -1113,7 +1112,7 @@
"@solidjs/router": "0.15.4", "@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"@tanstack/solid-virtual": "3.13.32", "@tanstack/solid-virtual": "3.13.28",
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.13", "@types/bun": "1.3.13",
@@ -1133,7 +1132,7 @@
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
"luxon": "3.6.1", "luxon": "3.6.1",
"marked": "17.0.6", "marked": "17.0.1",
"marked-shiki": "1.2.1", "marked-shiki": "1.2.1",
"opentui-spinner": "0.0.7", "opentui-spinner": "0.0.7",
"remeda": "2.26.0", "remeda": "2.26.0",
@@ -1175,7 +1174,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/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=="], "@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=="],
@@ -1185,7 +1184,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/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=="], "@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=="],
@@ -2039,27 +2038,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="], "@opentui/core": ["@opentui/core@0.4.3", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.3", "@opentui/core-darwin-x64": "0.4.3", "@opentui/core-linux-arm64": "0.4.3", "@opentui/core-linux-arm64-musl": "0.4.3", "@opentui/core-linux-x64": "0.4.3", "@opentui/core-linux-x64-musl": "0.4.3", "@opentui/core-win32-arm64": "0.4.3", "@opentui/core-win32-x64": "0.4.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-rrJfAk13tALDqldYjhc78eWQ+aKq1iknJgffIOg3OwyZoqQo+p6gtuqyhmWvXIfQzlNUbpgpCPcxbXlhMnlaHQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p5+7AAxpxGuDGagyQfewKtmTFnN7THvTVY4FyKqUtJomNaHdQXPHztapNNzMx0DGWbwOUbVKzpL+yc3CZY3chQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+fh0vEUE0lwVC7RW5ijYLRlTLp5NfvCRj8SzxDVd7IL2j2ssB6YXcfIbXq2EW7UGnrejwPRXf1tgUrIXW9KmOw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gl6qA5QJy6u8Cbt7gOtHbhhfMZ4qQDb0kEwFXHcMGmbnKzz4OHoq74D6tNjyvSQB9saoC7C6C0tvn2DcJOuNog=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="], "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8p8g8/AEq/xFGpQ7XcIFKcAqjc0QwsZcv+Ll9RbCDpUA56FGH6jfLDir0KYTNTgYXJTIrBIENI9K46VuxMUMQA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dXpJitiZdYE3hq2Pvx6e9I0uPQSOcnaLLp1pDgWAHv+3kvKSHEX//9Yr/pV/Ua6qqT7p+2D/K4vXNap/NKVo2w=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="], "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/QiFpCrpU2O7vy8QYmLIQYbvAtKDgmqcVjR7dGtqSzkiQk3ktNJoo5RozG7ueXnjung1Wp0nKldKxo2Csg/OrA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mx2zuOjrhm/z2SDS6RExIyjP/SnN/8QhhagxURUw0jQi/NssGSeAllu1cBAFFnhobJL5QLTE4FU4CRhUK9svgg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NuoqvWKGXaYnmlqvu7Gg2lLI6yVMnS9OfWBvxp+7Q+McSgHFSTQmYBXaPpvQ8HikpQXE1nCeMPtuSG4PdZHe2w=="],
"@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="], "@opentui/keymap": ["@opentui/keymap@0.4.3", "", { "dependencies": { "@opentui/core": "0.4.3" }, "peerDependencies": { "@opentui/react": "0.4.3", "@opentui/solid": "0.4.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-sinX0pyQBRrEvo89PSSUbSUDIYpL3xWo81VEfec58VFoVRB5FG48/deAtvRTQfJ8w1kgbzN8hzdOXdSm61zBmw=="],
"@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="], "@opentui/solid": ["@opentui/solid@0.4.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-RcV0+S8HMdXOASyr7HmJUBuTUIaFPzAxMDa44VftS5C2JUgrmAuWo0Njv1q3TWRB1owjHnyKhEfWGKq7A82wxw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2781,9 +2780,9 @@
"@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-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/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=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
@@ -3845,13 +3844,13 @@
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#83c0a07", {}, "anomalyco-ghostty-web-83c0a07", "sha512-Lf2v1agHkVUpMpHBWWuCZrhOEmcwwin5/Hboc9rZwQ7/CKkIh5rU1r1CvfLlhkMoFv+ed8z52RZ8hkzGZZj3MQ=="], "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "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=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -4313,7 +4312,7 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "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=="], "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
@@ -5655,11 +5654,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/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-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/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/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=="], "@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=="],
@@ -5671,9 +5666,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/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=="], "@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
@@ -6025,7 +6020,7 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@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=="],
@@ -6051,8 +6046,6 @@
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "@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=="], "@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=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
@@ -6169,10 +6162,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/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/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=="], "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=="],
@@ -6399,7 +6388,7 @@
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="], "opencode/@ai-sdk/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=="], "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=="],
@@ -6907,10 +6896,6 @@
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], "@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/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=="], "@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=="],
@@ -6981,14 +6966,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/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=="], "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=="], "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -7141,10 +7118,6 @@
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="], "opencode/@ai-sdk/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=="], "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=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -7393,8 +7366,6 @@
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@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/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
@@ -7411,10 +7382,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/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=="], "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=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
@@ -7485,8 +7452,6 @@
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "opencode/@ai-sdk/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-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=="], "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=="],
-19
View File
@@ -8,25 +8,6 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8"
export const awsStage = $app.stage === "production" ? "production" : "dev" export const awsStage = $app.stage === "production" ? "production" : "dev"
export const deployAws = $app.stage === awsStage export const deployAws = $app.stage === awsStage
if ($app.stage === "production") {
new cloudflare.DnsRecord("TrustCenter", {
zoneId: zoneID,
name: "trust.opencode.ai",
type: "CNAME",
content: "3a69a5bb27875189.vercel-dns-016.com",
proxied: false,
ttl: 60,
})
new cloudflare.DnsRecord("TrustCenterVerification", {
zoneId: zoneID,
name: "opencode.ai",
type: "TXT",
content: "compai-domain-verification=org_6993a99c6200a2d642bb115d",
ttl: 60,
})
}
new cloudflare.RegionalHostname("RegionalHostname", { new cloudflare.RegionalHostname("RegionalHostname", {
hostname: domain, hostname: domain,
regionKey: "us", regionKey: "us",
+33 -66
View File
@@ -8,8 +8,6 @@
makeWrapper, makeWrapper,
writableTmpDirAsHomeHook, writableTmpDirAsHomeHook,
autoPatchelfHook, autoPatchelfHook,
copyDesktopItems,
makeDesktopItem,
opencode, opencode,
}: }:
let let
@@ -29,12 +27,9 @@ stdenv.mkDerivation (finalAttrs: {
nodejs nodejs
makeWrapper makeWrapper
writableTmpDirAsHomeHook writableTmpDirAsHomeHook
] ] ++ lib.optionals stdenv.hostPlatform.isLinux [
++ lib.optionals stdenv.hostPlatform.isLinux [
autoPatchelfHook autoPatchelfHook
copyDesktopItems ] ++ lib.optionals stdenv.hostPlatform.isDarwin [
]
++ lib.optionals stdenv.hostPlatform.isDarwin [
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing. # Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
darwin.autoSignDarwinBinariesHook darwin.autoSignDarwinBinariesHook
]; ];
@@ -43,37 +38,20 @@ stdenv.mkDerivation (finalAttrs: {
(lib.getLib stdenv.cc.cc) (lib.getLib stdenv.cc.cc)
]; ];
desktopItems = lib.optional stdenv.hostPlatform.isLinux (makeDesktopItem {
name = "ai.opencode.desktop";
desktopName = "OpenCode";
exec = "opencode-desktop %U";
icon = "ai.opencode.desktop";
# Electron 41 derives X11 WM_CLASS from app.name.
startupWMClass = "OpenCode";
categories = [ "Development" ];
});
env = opencode.env // { env = opencode.env // {
ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
}; };
postPatch = # https://github.com/electron/electron/issues/31121
# NOTE: Relax Bun version check to be a warning instead of an error # mac builds use a .app bundle which doesnt have this issue
'' postPatch = lib.optionalString stdenv.isLinux ''
substituteInPlace packages/script/src/index.ts \ BASE_PATH=packages/desktop
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \ FILES=(src/main/windows.ts)
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}' for file in "''${FILES[@]}"; do
'' substituteInPlace $BASE_PATH/$file \
# https://github.com/electron/electron/issues/31121 --replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
# mac builds use a .app bundle which doesnt have this issue done
+ lib.optionalString stdenv.isLinux '' '';
BASE_PATH=packages/desktop
FILES=(src/main/windows.ts)
for file in "''${FILES[@]}"; do
substituteInPlace $BASE_PATH/$file \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
done
'';
preBuild = '' preBuild = ''
cp -r "${electron.dist}" $HOME/.electron-dist cp -r "${electron.dist}" $HOME/.electron-dist
@@ -98,38 +76,27 @@ stdenv.mkDerivation (finalAttrs: {
runHook postBuild runHook postBuild
''; '';
installPhase = '' installPhase =
runHook preInstall ''
'' runHook preInstall
+ lib.optionalString stdenv.hostPlatform.isDarwin '' ''
mkdir -p $out/Applications + lib.optionalString stdenv.hostPlatform.isDarwin ''
mv dist/mac*/*.app $out/Applications mkdir -p $out/Applications
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop mv dist/mac*/*.app $out/Applications
'' makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
+ lib.optionalString stdenv.hostPlatform.isLinux '' ''
mkdir -p $out/opt/opencode-desktop + lib.optionalString stdenv.hostPlatform.isLinux ''
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop mkdir -p $out/opt/opencode-desktop
install -Dm644 resources/icons/32x32.png \ cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
"$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png" makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
install -Dm644 resources/icons/64x64.png \ --inherit-argv0 \
"$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png" --set ELECTRON_FORCE_IS_PACKAGED 1 \
install -Dm644 resources/icons/128x128.png \ --add-flags $out/opt/opencode-desktop/resources/app.asar \
"$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png" --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
install -Dm644 resources/icons/128x128@2x.png \ ''
"$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png" + ''
install -Dm644 resources/icons/icon.png \ runHook postInstall
"$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png" '';
install -Dm644 resources/ai.opencode.desktop.metainfo.xml \
"$out/share/metainfo/ai.opencode.desktop.metainfo.xml"
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
--inherit-argv0 \
--set ELECTRON_FORCE_IS_PACKAGED 1 \
--add-flags $out/opt/opencode-desktop/resources/app.asar \
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
''
+ ''
runHook postInstall
'';
autoPatchelfIgnoreMissingDeps = [ autoPatchelfIgnoreMissingDeps = [
"libc.musl-x86_64.so.1" "libc.musl-x86_64.so.1"
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-qt11SKmOjq0KU542QFbs+u7YyJicn4drCcwCdg325yk=", "x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=",
"aarch64-linux": "sha256-z68doReXTrWS7HeiAjc0btIjAsvzeZZ7hXAlHr0c77Q=", "aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=",
"aarch64-darwin": "sha256-PILYH1Pi8XBvSkuZ+1sNnUTao5kba+m5Z8iJKx6YXPo=", "aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=",
"x86_64-darwin": "sha256-KpcJzP4m0SUavu/WaSffgzOxrHq8ljdy0GOzs9p16lo=" "x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk="
} }
} }
+8 -7
View File
@@ -40,10 +40,10 @@
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0", "@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.4.5", "@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.5", "@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.5", "@opentui/solid": "0.4.3",
"@tanstack/solid-virtual": "3.13.32", "@tanstack/solid-virtual": "3.13.28",
"@shikijs/stream": "4.2.0", "@shikijs/stream": "4.2.0",
"ulid": "3.0.1", "ulid": "3.0.1",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
@@ -70,7 +70,7 @@
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"luxon": "3.6.1", "luxon": "3.6.1",
"marked": "17.0.6", "marked": "17.0.1",
"marked-shiki": "1.2.1", "marked-shiki": "1.2.1",
"remend": "1.3.0", "remend": "1.3.0",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
@@ -152,9 +152,10 @@
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.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", "@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", "@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", "@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.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.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 ?? {}) }, input.settings ?? {})
if (input.locale) { if (input.locale) {
await page.addInitScript((locale) => { await page.addInitScript((locale) => {
@@ -24,7 +24,6 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
await page.addInitScript( await page.addInitScript(
({ directory, draftID, server }) => { ({ directory, draftID, server }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } })) localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
localStorage.setItem( localStorage.setItem(
"opencode.window.browser.dat:tabs", "opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]), JSON.stringify([{ type: "draft", draftID, server, directory }]),
@@ -1,50 +0,0 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/PromptInputV2Editing"
const projectID = "proj_prompt_input_v2_editing"
const sessionID = "ses_prompt_input_v2_editing"
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "prompt-input-v2-editing",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: sessionID,
slug: "prompt-input-v2-editing",
projectID,
directory,
title: "Prompt input V2 editing",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]')
await expectAppVisible(composer)
await input.fill("keep me")
await composer.getByRole("button", { name: "Add images and files" }).click()
await page.getByRole("menuitem", { name: "Commands" }).click()
await page.locator('[data-suggestion-id="model.choose"]').click()
await expect(input).toHaveText("keep me")
})
@@ -54,15 +54,18 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
}) })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const composer = page.locator('[data-component="prompt-input-v2"]') const composer = page.locator('[data-component="session-composer"]')
const input = composer.locator('[data-component="prompt-input"]') const input = composer.locator('[data-component="prompt-input"]')
const control = composer.getByRole("button", { name: "Choose model variant" }) const control = composer.locator('[data-component="prompt-variant-control"]')
await expectAppVisible(composer) await expectAppVisible(composer)
await idleComposer(page) await idleComposer(page)
await expect(control).toBeHidden()
await composer.hover()
await expect(control).toBeVisible() await expect(control).toBeVisible()
await control.click() await control.locator('[data-action="prompt-model-variant"]').click()
const high = page.getByRole("menuitemradio", { name: "high" }) const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible() await expect(high).toBeVisible()
await page.mouse.move(0, 0) await page.mouse.move(0, 0)
@@ -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" })
}
@@ -1,7 +1,6 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/RequestDocks" const directory = "C:/OpenCode/RequestDocks"
@@ -101,67 +100,6 @@ test("shows a pending permission dock", async ({ page }) => {
expect(request.postDataJSON()).toEqual({ response: "once" }) 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( async function mockServer(
page: Page, page: Page,
requests: { requests: {
@@ -17,14 +17,12 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
const initialPageSize = 20 const assistants = Array.from({ length: 14 }, (_, index) =>
const historyPageSize = 200
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
parentID: userID, parentID: userID,
created: 1700000001000 + index * 1_000, created: 1700000001000 + index * 1_000,
completed: index < initialPageSize, completed: index < 13,
}), }),
) )
const messages = [userMessage(), ...assistants] const messages = [userMessage(), ...assistants]
@@ -48,7 +46,7 @@ const scenarios = [
test.use({ viewport: { width: 646, height: 1385 } }) test.use({ viewport: { width: 646, height: 1385 } })
for (const scenario of scenarios) { 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 requests: { before?: string; phase: "start" | "end" }[] = []
const pages: { before?: string; limit: number }[] = [] const pages: { before?: string; limit: number }[] = []
const roots: { sessionID: string; messageID: string }[] = [] const roots: { sessionID: string; messageID: string }[] = []
@@ -103,51 +101,36 @@ for (const scenario of scenarios) {
} }
}, },
}) })
await page.addInitScript(() => { await page.addInitScript(
const visibleParts = () => { ({ userPartID, lastPartID }) => {
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]") const state = { armed: false, hidden: false, samples: 0, stop: false }
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport") ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
const view = viewport?.getBoundingClientRect() const sample = () => {
if (!viewport || !view) return [] if (state.armed) {
return [...viewport.querySelectorAll<HTMLElement>("[data-timeline-part-id]")] const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
.filter((part) => { const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
const rect = part.getBoundingClientRect() const view = viewport?.getBoundingClientRect()
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom const visible = (partID: string) => {
}) const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
.flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : [])) const rect = part?.getBoundingClientRect()
} return (
const state = { !!rect &&
armed: false, !!view &&
hidden: false, rect.width > 0 &&
visibleParts: [] as string[], rect.height > 0 &&
samples: 0, rect.bottom > view.top &&
stop: false, rect.top < view.bottom
arm() { )
state.visibleParts = visibleParts() }
state.armed = true if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true
}, state.samples++
}
;(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
)
} }
if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID))) if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
state.hidden = true
state.samples++
} }
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 page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection() await transport.waitForConnection()
@@ -160,28 +143,23 @@ for (const scenario of scenarios) {
"messages:start:latest", "messages:start:latest",
"messages:end:latest", "messages:end:latest",
`message:${userID}`, `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(() => { await page.evaluate(() => {
;( ;(
window as Window & { window as Window & {
__historyRootProbe?: { arm(): void } __historyRootProbe?: { armed: boolean }
} }
).__historyRootProbe!.arm() ).__historyRootProbe!.armed = true
}) })
await waitForProbeSamples(page, 0) await waitForProbeSamples(page, 0)
expect(await visibleContentHidden(page)).toBe(false) expect(await historyRootHidden(page)).toBe(false)
const beforeHistory = await probeSamples(page) const beforeHistory = await probeSamples(page)
history.resolve() history.resolve()
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length) await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14)
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await waitForProbeSamples(page, beforeHistory) await waitForProbeSamples(page, beforeHistory)
expect(pages).toEqual([ expect(pages[0]).toEqual({ before: undefined, limit: 2 })
{ before: undefined, limit: initialPageSize },
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
])
expect(roots).toEqual([{ sessionID, messageID: userID }]) expect(roots).toEqual([{ sessionID, messageID: userID }])
const message = messageUpdated(scenario.info) 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( return page.evaluate(
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden, () => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
) )
@@ -1,5 +1,5 @@
import { base64Encode } from "@opencode-ai/core/util/encode" 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 { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
@@ -7,11 +7,10 @@ const directory = "C:/OpenCode/TerminalComposerFocus"
const projectID = "proj_terminal_composer_focus" const projectID = "proj_terminal_composer_focus"
const sessionID = "ses_terminal_composer_focus" const sessionID = "ses_terminal_composer_focus"
const ptyID = "pty_terminal_composer_focus" const ptyID = "pty_terminal_composer_focus"
const newPtyID = "pty_terminal_composer_focus_new"
test.use({ viewport: { width: 1440, height: 900 } }) 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, { await mockOpenCodeServer(page, {
directory, directory,
project: { project: {
@@ -68,9 +67,7 @@ test.beforeEach(async ({ page }) => {
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) 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 page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus") 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).toBeFocused()
await expect(composer).toHaveText("a") 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()],
})
@@ -736,5 +736,5 @@ async function switchTitlebarSession(page: Page, sessionID: string, title: strin
} }
async function expectSessionReady(page: Page) { async function expectSessionReady(page: Page) {
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" })) await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
} }
-3
View File
@@ -10,9 +10,6 @@
"./performance/timeline-stability/fixture.test.ts", "./performance/timeline-stability/fixture.test.ts",
"./performance/timeline-stability/fixture.ts", "./performance/timeline-stability/fixture.ts",
"./performance/unit/visual-stability.test.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/new-session-panel-corner.spec.ts",
"./regression/session-timeline-context-resize.spec.ts", "./regression/session-timeline-context-resize.spec.ts",
"./utils/**/*.ts" "./utils/**/*.ts"
-29
View File
@@ -56,11 +56,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const path = url.pathname const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/health") return json(route, { healthy: true }) if (path === "/global/health") return json(route, { healthy: true })
if (path === "/api/session")
return json(route, {
data: config.sessions.map((session) => v2Session(session, config.directory)),
cursor: {},
})
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false }) if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
if (path === "/permission") if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
@@ -137,30 +132,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}) })
} }
function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) {
const time = session.time && typeof session.time === "object" ? session.time : {}
return {
id: session.id,
parentID: session.parentID,
projectID: session.projectID ?? "project",
cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: {
created: "created" in time && typeof time.created === "number" ? time.created : 0,
updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
...(session.time && typeof session.time === "object" && "archived" in session.time
? { archived: session.time.archived }
: {}),
},
title: session.title ?? session.id,
location: {
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
},
...(typeof session.path === "string" ? { subpath: session.path } : {}),
}
}
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) { function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
return route.fulfill({ return route.fulfill({
status, status,
+1 -1
View File
@@ -162,7 +162,7 @@ export async function installSseTransport<T>(
const request = new Request(input, init) const request = new Request(input, init)
const url = new URL(request.url) const url = new URL(request.url)
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
return originalFetch(request) return originalFetch(input, init)
const id = ++nextConnectionID const id = ++nextConnectionID
const record = { const record = {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.18.4", "version": "1.17.18",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -81,7 +81,7 @@
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", "ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
@@ -37,14 +37,6 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
} }
describe("SerializeAddon", () => { describe("SerializeAddon", () => {
test("preserves color scheme reporting mode", async () => {
const { term, addon } = createTerminal()
await writeAndWait(term, "\x1b[?2031h")
expect(addon.serialize().startsWith("\x1b[?2031h")).toBe(true)
expect(addon.serialize({ excludeModes: true }).startsWith("\x1b[?2031h")).toBe(false)
})
describe("ANSI color preservation", () => { describe("ANSI color preservation", () => {
test("should preserve text attributes (bold, italic, underline)", async () => { test("should preserve text attributes (bold, italic, underline)", async () => {
const { term, addon } = createTerminal() const { term, addon } = createTerminal()
+1 -9
View File
@@ -89,13 +89,6 @@ const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined =
return { active, normal, alternate } return { active, normal, alternate }
} }
const getTerminalMode = (value: ITerminalCore, mode: number) => {
if (!isRecord(value)) return false
const terminal = value.wasmTerm
if (!isRecord(terminal) || typeof terminal.getMode !== "function") return false
return terminal.getMode(mode) === true
}
// ============================================================================ // ============================================================================
// Types // Types
// ============================================================================ // ============================================================================
@@ -551,8 +544,7 @@ export class SerializeAddon implements ITerminalAddon {
return "" return ""
} }
let content = !options?.excludeModes && getTerminalMode(this._terminal, 2031) ? "\u001b[?2031h" : "" let content = options?.range
content += options?.range
? this._serializeBufferByRange(normalBuffer, options.range, true) ? this._serializeBufferByRange(normalBuffer, options.range, true)
: this._serializeBufferByScrollback(normalBuffer, options?.scrollback) : this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
+28 -28
View File
@@ -9,16 +9,7 @@ import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo" import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
type BaseRouterProps,
Navigate,
Route,
Router,
useLocation,
useNavigate,
useParams,
useSearchParams,
} from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect" import { Effect } from "effect"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
@@ -38,7 +29,6 @@ import {
Show, Show,
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command" import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
@@ -163,7 +153,8 @@ function LegacyTargetSessionRedirect() {
} }
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected // 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) { function SelectedServerProviders(props: ParentProps) {
return ( return (
<ServerKey> <ServerKey>
@@ -216,7 +207,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed> <Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<ServerSDKProvider server={conn}> <ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}> <ServerSyncProvider server={conn}>
<ModelsProvider directory={directory}> <DraftServerScopedProviders directory={directory}>
<SDKProvider directory={directory}> <SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}> <DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders> <DraftProviders>
@@ -224,7 +215,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
</DraftProviders> </DraftProviders>
</DirectoryDataProvider> </DirectoryDataProvider>
</SDKProvider> </SDKProvider>
</ModelsProvider> </DraftServerScopedProviders>
</ServerSyncProvider> </ServerSyncProvider>
</ServerSDKProvider> </ServerSDKProvider>
</Show> </Show>
@@ -318,21 +309,24 @@ function DesktopCommands() {
// Server-scoped providers shared by the legacy shell and the top-level new shell. // Server-scoped providers shared by the legacy shell and the top-level new shell.
type ServerScopedShellProps = ParentProps<{ type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined directory?: () => string | undefined
sessionID?: () => string | undefined
serverScoped?: JSX.Element serverScoped?: JSX.Element
}> }>
function ServerScopedProviders(props: ServerScopedShellProps) { function ServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<LayoutProvider> <PermissionProvider directory={props.directory}>
{props.serverScoped} <LayoutProvider>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider> {props.serverScoped}
</LayoutProvider> <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</LayoutProvider>
</PermissionProvider>
) )
} }
function LegacyServerScopedShell(props: ServerScopedShellProps) { function LegacyServerScopedShell(props: ServerScopedShellProps) {
return ( return (
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}> <ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
<LegacyLayout>{props.children}</LegacyLayout> <LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders> </ServerScopedProviders>
) )
@@ -348,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. // The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context. // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) { function DraftProviders(props: ParentProps) {
@@ -557,15 +559,13 @@ export function AppInterface(props: {
component={props.router ?? Router} component={props.router ?? Router}
root={(routerProps) => ( root={(routerProps) => (
<TabsProvider> <TabsProvider>
<PermissionProvider> <NotificationProvider>
<NotificationProvider> <ServerShell>
<ServerShell> <Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}> <NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout> </Show>
</Show> </ServerShell>
</ServerShell> </NotificationProvider>
</NotificationProvider>
</PermissionProvider>
</TabsProvider> </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

+136 -90
View File
@@ -1,18 +1,17 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useNavigate } from "@solidjs/router"
import { createMemo, onCleanup } from "solid-js" import { createMemo, onCleanup } from "solid-js"
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout, type LocalProject } from "@/context/layout" import { useLayout } from "@/context/layout"
import { ServerConnection } from "@/context/server" import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync"
import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
export type CommandPaletteEntry = { export type CommandPaletteEntry = {
id: string id: string
@@ -25,8 +24,6 @@ export type CommandPaletteEntry = {
path?: string path?: string
directory?: string directory?: string
sessionID?: string sessionID?: string
server?: ServerConnection.Key
project?: LocalProject
archived?: number archived?: number
updated?: number updated?: number
} }
@@ -78,25 +75,28 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) { export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
const command = useCommand() const command = useCommand()
const global = useGlobal()
const language = useLanguage() const language = useLanguage()
const layout = useLayout()
const file = useFile() const file = useFile()
const dialog = useDialog() const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()() const serverSDK = useServerSDK()()
const serverCtx = global.ensureServerCtx(serverSDK.server) const serverSync = useServerSync()
const appTabs = useTabs() const { params, tabs } = useSessionLayout()
const { tabs: sessionTabs } = useSessionLayout()
const openFile = createCommandPaletteFileOpener(props.onOpenFile) const openFile = createCommandPaletteFileOpener(props.onOpenFile)
const state = { cleanup: undefined as (() => void) | void, committed: false } const state = { cleanup: undefined as (() => void) | void, committed: false }
const filesOnly = () => props.filesOnly?.() ?? false const filesOnly = () => props.filesOnly?.() ?? false
const allowedCommands = createMemo(() => { const allowedCommands = createMemo(() => {
if (filesOnly()) return [] if (filesOnly()) return []
return commandPaletteOptions(command.options) return command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
}) })
const commandEntries = createMemo(() => { const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands") const category = language.t("palette.group.commands")
return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category)) return allowedCommands().map((option) => createCommandEntry(option, category))
}) })
const preferredCommandEntries = createMemo(() => { const preferredCommandEntries = createMemo(() => {
const all = allowedCommands() const all = allowedCommands()
@@ -105,11 +105,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = language.t("palette.group.commands") const category = language.t("palette.group.commands")
return sorted.map((option) => createCommandPaletteCommandEntry(option, category)) return sorted.map((option) => createCommandEntry(option, category))
}) })
const tabState = createSessionTabs({ const tabState = createSessionTabs({
tabs: sessionTabs, tabs,
pathFromTab: file.pathFromTab, pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}) })
@@ -140,12 +140,36 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
.map((path) => createCommandPaletteFileEntry(path, category)) .map((path) => createCommandPaletteFileEntry(path, category))
}) })
const sessions = createServerSessionEntries({ const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
server: ServerConnection.key(serverSDK.server), const project = createMemo(() => {
opened: serverCtx.projects.list, const directory = projectDirectory()
stored: () => serverCtx.sync.data.project, if (!directory) return undefined
load: (search, signal) => return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }), })
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const sessions = createSessionEntries({
workspaces,
label: (directory) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
},
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
untitled: () => language.t("command.session.new"), untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"), category: () => language.t("command.category.session"),
}) })
@@ -167,17 +191,8 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
return return
} }
if (item.type === "session") { if (item.type === "session") {
if (!item.sessionID || !item.server) return if (!item.directory || !item.sessionID) return
const directory = item.project?.worktree ?? item.directory navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
if (directory) {
serverCtx.projects.open(directory)
serverCtx.projects.touch(directory)
}
const tab = appTabs.addSessionTab({
server: item.server,
sessionId: item.sessionID,
})
appTabs.select(tab)
return return
} }
if (!item.path) return if (!item.path) return
@@ -203,7 +218,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
} }
} }
export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
return { return {
id: "command:" + option.id, id: "command:" + option.id,
type: "command", type: "command",
@@ -215,65 +230,96 @@ export function createCommandPaletteCommandEntry(option: CommandOption, category
} }
} }
export function createServerSessionEntries(props: { function createSessionEntries(props: {
server: ServerConnection.Key workspaces: () => string[]
opened: () => LocalProject[] label: (directory: string) => string
stored: () => Project[] load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
untitled: () => string untitled: () => string
category: () => string category: () => string
}) { }) {
let abort: AbortController | undefined const state: {
token: number
inflight: Promise<CommandPaletteEntry[]> | undefined
cached: CommandPaletteEntry[] | undefined
} = { token: 0, inflight: undefined, cached: undefined }
onCleanup(() => abort?.abort()) return (text: string) => {
if (!text.trim()) {
return async (text: string): Promise<CommandPaletteEntry[]> => { state.token += 1
const search = text.trim() state.inflight = undefined
if (!search) { state.cached = undefined
abort?.abort() return [] as CommandPaletteEntry[]
return []
} }
abort?.abort() if (state.cached) return state.cached
const current = new AbortController() if (state.inflight) return state.inflight
abort = current
await new Promise<void>((resolve) => { const current = state.token
const timer = setTimeout(resolve, 100) const dirs = props.workspaces()
current.signal.addEventListener( if (dirs.length === 0) return [] as CommandPaletteEntry[]
"abort",
() => { state.inflight = Promise.all(
clearTimeout(timer) dirs.map((directory) => {
resolve() const description = props.label(directory)
}, return props
{ once: true }, .load(directory)
) .then((result) =>
}) (result.data ?? [])
if (current.signal.aborted) return [] .filter((session) => !!session?.id)
const opened = props.opened() .map((session) => ({
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : []))) id: session.id,
const stored = props.stored().map((project) => ({ ...project, expanded: false })) title: session.title ?? props.untitled(),
const storedByID = new Map(stored.map((project) => [project.id, project] as const)) description,
return props directory,
.load(search, current.signal) archived: session.time?.archived,
.then((result) => updated: session.time?.updated,
(result.data ?? []) })),
.filter((session) => !session.time.archived) )
.map((session) => { .catch(() => [] as SessionEntryInput[])
const project = }),
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID) )
return { .then((results) => {
id: `session:${props.server}:${session.id}`, if (state.token !== current) return [] as CommandPaletteEntry[]
type: "session" as const, const seen = new Set<string>()
title: session.title || props.untitled(), const next = results
description: project ? displayName(project) : session.project?.name || getFilename(session.directory), .flat()
category: props.category(), .filter((item) => {
directory: session.directory, const key = `${item.directory}:${item.id}`
sessionID: session.id, if (seen.has(key)) return false
server: props.server, seen.add(key)
project, return true
updated: session.time.updated, })
} .map((item) => createSessionEntry(item, props.category()))
}), state.cached = next
) return next
})
.catch(() => [] as CommandPaletteEntry[]) .catch(() => [] as CommandPaletteEntry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
}
type SessionEntryInput = {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
}
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
return {
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
} }
} }
+1 -53
View File
@@ -5,7 +5,6 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
type Mem = Performance & { type Mem = Performance & {
memory?: { memory?: {
@@ -108,45 +107,8 @@ function Cell(props: {
) )
} }
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
const content = () => (
<button
type="button"
aria-label="Force focus styles on all interactive elements"
aria-pressed={props.active}
classList={{
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
"min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
"bg-surface-raised-base text-text-strong": props.active,
}}
onClick={props.onClick}
>
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
<span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}>
{props.active ? "ON" : "OFF"}
</span>
</button>
)
if (props.inline) {
return (
<TooltipV2 value="Force focus styles on all interactive elements" placement="top">
{content()}
</TooltipV2>
)
}
return (
<Tooltip value="Force focus styles on all interactive elements" placement="top">
{content()}
</Tooltip>
)
}
export function DebugBar(props: { inline?: boolean } = {}) { export function DebugBar(props: { inline?: boolean } = {}) {
const language = useLanguage() const language = useLanguage()
const platform = usePlatform()
const location = useLocation() const location = useLocation()
const routing = useIsRouting() const routing = useIsRouting()
const [state, setState] = createStore({ const [state, setState] = createStore({
@@ -154,7 +116,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
delay: undefined as number | undefined, delay: undefined as number | undefined,
fps: undefined as number | undefined, fps: undefined as number | undefined,
gap: undefined as number | undefined, gap: undefined as number | undefined,
focus: false,
heap: { heap: {
limit: undefined as number | undefined, limit: undefined as number | undefined,
used: undefined as number | undefined, used: undefined as number | undefined,
@@ -181,16 +142,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
} }
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`) const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na())) const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
const toggleFocus = async () => {
if (!platform.setForceFocus) return
const enabled = !state.focus
await platform.setForceFocus(enabled)
setState("focus", enabled)
}
onCleanup(() => {
if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined)
})
let prev = "" let prev = ""
let start = 0 let start = 0
@@ -539,11 +490,8 @@ export function DebugBar(props: { inline?: boolean } = {}) {
bad={bad(heap(), 0.8)} bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined} dim={state.heap.used === undefined}
inline={props.inline} inline={props.inline}
wide={!platform.setForceFocus} wide
/> />
{platform.setForceFocus && (
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
)}
</div> </div>
</aside> </aside>
) )
@@ -5,20 +5,13 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" import { formatKeybindParts } from "@/context/command"
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { getRelativeTime } from "@/utils/time" import { getRelativeTime } from "@/utils/time"
import { import {
createCommandPaletteCommandEntry,
createCommandPaletteFileEntry, createCommandPaletteFileEntry,
createCommandPaletteModel, createCommandPaletteModel,
createServerSessionEntries,
uniqueCommandPaletteEntries, uniqueCommandPaletteEntries,
type CommandPaletteEntry, type CommandPaletteEntry,
} from "./command-palette" } from "./command-palette"
@@ -37,6 +30,9 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) {
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) { export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props) const palette = createCommandPaletteModel(props)
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const loadItems = async (text: string) => { const loadItems = async (text: string) => {
const q = text.trim() const q = text.trim()
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
@@ -45,105 +41,16 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
const category = palette.language.t("palette.group.files") const category = palette.language.t("palette.group.files")
return [ return [
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)), ...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
...nextSessions, ...nextSessions.filter((entry) => matchesEntry(entry, q)),
...files.map((path) => createCommandPaletteFileEntry(path, category)), ...files.map((path) => createCommandPaletteFileEntry(path, category)),
] ]
} }
return ( const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
<CommandPaletteView
placeholder={palette.language.t("palette.search.placeholder")}
loadItems={loadItems}
highlight={palette.highlight}
select={palette.select}
close={palette.close}
/>
)
}
export function DialogHomeCommandPaletteV2(props: {
server: ServerConnection.Any
onSelectSession: (entry: CommandPaletteEntry) => void
}) {
const command = useCommand()
const dialog = useDialog()
const global = useGlobal()
const language = useLanguage()
const serverCtx = global.ensureServerCtx(props.server)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category))
})
const sessions = createServerSessionEntries({
server: ServerConnection.key(props.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) =>
serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
const highlight = (item: CommandPaletteEntry | undefined) => {
state.cleanup?.()
state.cleanup = undefined
if (item?.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const select = (item: CommandPaletteEntry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") props.onSelectSession(item)
}
const loadItems = async (text: string) => {
const query = text.trim()
if (!query) return commandEntries().slice(0, 5)
return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))]
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return (
<CommandPaletteView
placeholder={language.t("palette.search.placeholder.home")}
loadItems={loadItems}
highlight={highlight}
select={select}
close={() => dialog.close()}
/>
)
}
function CommandPaletteView(props: {
placeholder: string
loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
highlight: (item: CommandPaletteEntry | undefined) => void
select: (item: CommandPaletteEntry | undefined) => void
close: () => void
}) {
const language = useLanguage()
const tabs = useTabs()
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke. // Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? [])) const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
const groupedEntries = createMemo(() => groups(visibleEntries())) const groupedEntries = createMemo(() => groups(visibleEntries()))
const activeEntry = createMemo(() => visibleEntries()[active()]) const activeEntry = createMemo(() => visibleEntries()[active()])
const openSessions = createMemo(
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
)
createEffect(() => { createEffect(() => {
query() query()
@@ -152,7 +59,7 @@ function CommandPaletteView(props: {
}) })
createEffect(() => { createEffect(() => {
props.highlight(activeEntry()) palette.highlight(activeEntry())
}) })
let resultsRef: HTMLDivElement | undefined let resultsRef: HTMLDivElement | undefined
@@ -179,12 +86,12 @@ function CommandPaletteView(props: {
} }
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault() event.preventDefault()
props.select(activeEntry()) palette.select(activeEntry())
return return
} }
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
props.close() palette.close()
} }
} }
@@ -198,7 +105,7 @@ function CommandPaletteView(props: {
autocomplete="off" autocomplete="off"
spellcheck={false} spellcheck={false}
appearance="large" appearance="large"
placeholder={props.placeholder} placeholder={palette.language.t("palette.search.placeholder")}
leadingIcon={<Icon name="magnifying-glass" />} leadingIcon={<Icon name="magnifying-glass" />}
onInput={(event) => setQuery(event.currentTarget.value)} onInput={(event) => setQuery(event.currentTarget.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -210,7 +117,7 @@ function CommandPaletteView(props: {
when={visibleEntries().length > 0} when={visibleEntries().length > 0}
fallback={ fallback={
<div class="command-palette-v2-state"> <div class="command-palette-v2-state">
{entries.loading ? language.t("common.loading") : language.t("palette.empty")} {entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
</div> </div>
} }
> >
@@ -225,14 +132,9 @@ function CommandPaletteView(props: {
<PaletteRow <PaletteRow
item={item} item={item}
active={activeEntry()?.id === item.id} active={activeEntry()?.id === item.id}
language={language} language={palette.language}
sessionOpen={
item.server && item.sessionID
? openSessions().has(`${item.server}\0${item.sessionID}`)
: false
}
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))} onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
onSelect={() => props.select(item)} onSelect={() => palette.select(item)}
/> />
)} )}
</For> </For>
@@ -251,19 +153,13 @@ function PaletteRow(props: {
item: CommandPaletteEntry item: CommandPaletteEntry
active: boolean active: boolean
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
sessionOpen: boolean
onActive: () => void onActive: () => void
onSelect: () => void onSelect: () => void
}) { }) {
const session = () =>
props.item.server && props.item.directory && props.item.sessionID
? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID }
: undefined
return ( return (
<button <button
type="button" type="button"
class="command-palette-v2-row group" class="command-palette-v2-row"
role="option" role="option"
aria-selected={props.active} aria-selected={props.active}
data-active={props.active ? "" : undefined} data-active={props.active ? "" : undefined}
@@ -301,25 +197,7 @@ function PaletteRow(props: {
</Match> </Match>
<Match when={props.item.type === "session"}> <Match when={props.item.type === "session"}>
<div class="command-palette-v2-row-main"> <div class="command-palette-v2-row-main">
<div class="relative shrink-0"> <Icon name="status" class="command-palette-v2-row-icon" />
<Show when={props.sessionOpen}>
<span
aria-hidden="true"
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
style={{ right: "calc(100% + 4px)" }}
/>
</Show>
<Show when={session()}>
{(session) => (
<SessionTabAvatar
project={props.item.project}
directory={session().directory}
sessionId={session().sessionID}
server={session().server}
/>
)}
</Show>
</div>
<div class="command-palette-v2-row-text"> <div class="command-palette-v2-row-text">
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}> <span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.title} {props.item.title}
@@ -1,73 +0,0 @@
// @ts-nocheck
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { mockProviderAuth } from "@/context/server-sync"
import { onCleanup, onMount } from "solid-js"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
function ConnectProviderDialogStory() {
const dialog = useDialog()
const open = () => dialog.show(() => <DialogConnectProvider />)
onMount(open)
return (
<Button variant="secondary" onClick={open}>
Open connect provider dialog
</Button>
)
}
function ProviderConnectionDialogStory(props) {
onCleanup(mockProviderAuth(props.provider, props.methods))
const dialog = useDialog()
const controller = useProviderConnectController()
controller.select(props.provider)
const open = () => dialog.show(() => <DialogConnectProvider controller={controller} />)
onMount(open)
return (
<Button variant="secondary" onClick={open}>
Open {props.provider} connection dialog
</Button>
)
}
function renderConnection(provider, methods) {
return () => (
<QueryClientProvider client={new QueryClient()}>
<ProviderConnectionDialogStory provider={provider} methods={methods} />
</QueryClientProvider>
)
}
export default {
title: "App/Dialogs/Connect Provider",
id: "app-dialog-connect-provider",
}
export const V2 = {
render: () => (
<QueryClientProvider client={new QueryClient()}>
<ConnectProviderDialogStory />
</QueryClientProvider>
),
}
export const ApiKey = {
render: renderConnection("openrouter", [{ type: "api", label: "API key" }]),
}
export const OpenCodeZen = {
render: renderConnection("opencode", [{ type: "api", label: "API key" }]),
}
export const LoginMethods = {
render: renderConnection("openai", [
{ type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
{ type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
{ type: "api", label: "API key" },
]),
}
@@ -9,9 +9,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { Tag } from "@opencode-ai/ui/tag" import { Tag } from "@opencode-ai/ui/tag"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { import {
type Accessor, type Accessor,
@@ -19,8 +16,6 @@ import {
createEffect, createEffect,
createMemo, createMemo,
createResource, createResource,
createUniqueId,
For,
Match, Match,
onCleanup, onCleanup,
onMount, onMount,
@@ -32,7 +27,6 @@ import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider" import { CustomProviderForm } from "./dialog-custom-provider"
@@ -56,22 +50,32 @@ export const DialogConnectProvider: Component<{
const fallback = useProviderConnectController() const fallback = useProviderConnectController()
const controller = props.controller ?? fallback const controller = props.controller ?? fallback
const language = useLanguage() const language = useLanguage()
const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns
const reset = controller.back const reset = controller.back
const back = { current: reset } const back = { current: reset }
let focusHost: HTMLDivElement | undefined
const holdFocus = () => focusHost?.focus({ preventScroll: true })
const select = (provider?: string) => { const select = (provider?: string) => {
back.current = reset back.current = reset
controller.select(provider) controller.select(provider)
} }
function Content() { return (
return ( <Dialog
class="h-full"
transition
title={
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
/>
</Show>
}
>
<Switch> <Switch>
<Match when={controller.selected() === CUSTOM_ID}> <Match when={controller.selected() === CUSTOM_ID}>
<CustomProviderForm autofocus={!newLayout()} /> <CustomProviderForm />
</Match> </Match>
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}> <Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
{(provider) => ( {(provider) => (
@@ -84,76 +88,14 @@ export const DialogConnectProvider: Component<{
)} )}
</Match> </Match>
<Match when={true}> <Match when={true}>
<ProviderPicker <ProviderPicker directory={props.directory} onSelect={select} />
directory={props.directory}
onSelect={select}
onPrepare={newLayout() ? holdFocus : undefined}
/>
</Match> </Match>
</Switch> </Switch>
) </Dialog>
}
return (
<Show
when={newLayout()}
fallback={
<Dialog
class="h-full"
transition
title={
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
/>
</Show>
}
>
<Content />
</Dialog>
}
>
<DialogV2
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
>
<DialogHeader closeLabel={language.t("common.close")}>
<Show
when={controller.selected()}
fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
>
<Icon name="arrow-left" size="small" />
</button>
</Show>
</DialogHeader>
<DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
<div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
<Content />
</div>
</DialogBody>
</DialogV2>
</Show>
) )
} }
function ProviderPicker(props: { function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
directory?: Accessor<string | undefined>
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const settings = useSettings()
if (settings.general.newLayoutDesigns())
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
const providers = useProviders(props.directory) const providers = useProviders(props.directory)
const language = useLanguage() const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular") const popularGroup = () => language.t("dialog.provider.group.popular")
@@ -221,171 +163,6 @@ function ProviderPicker(props: {
) )
} }
function ProviderPickerV2(props: {
directory?: Accessor<string | undefined>
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const providers = useProviders(props.directory)
const language = useLanguage()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const [store, setStore] = createStore({
filter: "",
active: undefined as string | undefined,
connecting: undefined as string | undefined,
})
const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
const all = createMemo(() => {
language.locale()
const query = store.filter.trim().toLowerCase()
const values = [custom(), ...providers.all().values()]
if (!query) return values
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
})
const popular = createMemo(() =>
all()
.filter((provider) => featured.includes(provider.id))
.sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)),
)
const other = createMemo(() =>
all()
.filter((provider) => !featured.includes(provider.id))
.sort((a, b) => {
if (a.id === CUSTOM_ID) return -1
if (b.id === CUSTOM_ID) return 1
return a.name.localeCompare(b.name)
}),
)
const rows = createMemo(() => [...popular(), ...other()])
let picker: HTMLDivElement | undefined
let search: HTMLInputElement | undefined
onMount(() => search?.focus({ preventScroll: true }))
const connect = (provider: string) => {
props.onPrepare?.()
if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
props.onSelect(provider)
return
}
if (store.connecting) return
setStore("connecting", provider)
void serverSDK()
.client.provider.auth()
.then((response) => {
serverSync().set("provider_auth", response.data ?? {})
props.onSelect(provider)
})
.catch(() => props.onSelect(provider))
}
const move = (event: KeyboardEvent, direction: number) => {
const items = rows()
if (items.length === 0) return
const index = items.findIndex((provider) => provider.id === store.active)
const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
setStore("active", items[next].id)
picker
?.querySelector<HTMLElement>(`[data-provider-id="${CSS.escape(items[next].id)}"]`)
?.focus({ preventScroll: true })
event.preventDefault()
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowDown") return move(event, 1)
if (event.key === "ArrowUp") return move(event, -1)
if (event.key !== "Enter" || !store.active) return
connect(store.active)
event.preventDefault()
}
return (
<div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}>
<div class="shrink-0 px-1 pt-px">
<TextInputV2
ref={search}
type="search"
class="!w-full [font-family:var(--v2-font-family-sans)]"
leadingIcon={<Icon name="magnifying-glass" size="small" />}
placeholder={language.t("dialog.provider.search.placeholder")}
value={store.filter}
onInput={(event) => {
setStore({ filter: event.currentTarget.value, active: undefined })
}}
/>
</div>
<div class="relative min-h-0 flex-1">
<div class="flex size-full min-h-0 flex-col gap-4 overflow-y-auto pb-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<For
each={[
{ title: language.t("dialog.provider.group.popular"), items: popular },
{ title: language.t("dialog.provider.group.other"), items: other },
]}
>
{(group) => (
<Show when={group.items().length > 0}>
<section class="flex flex-col">
<div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
{group.title}
</div>
<For each={group.items()}>
{(provider) => (
<button
type="button"
data-provider-id={provider.id}
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
onMouseEnter={() => setStore("active", provider.id)}
disabled={store.connecting !== undefined}
aria-busy={store.connecting === provider.id}
onClick={() => connect(provider.id)}
>
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />
<span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
{language.t(
provider.id === "opencode"
? "dialog.provider.opencode.tagline"
: "dialog.provider.opencodeGo.tagline",
)}
</span>
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
{language.t("dialog.provider.tag.recommended")}
</span>
</Show>
<Show when={provider.id === CUSTOM_ID}>
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
{language.t("settings.providers.tag.custom")}
</span>
</Show>
<Show when={store.connecting === provider.id}>
<Spinner class="ml-auto size-4 shrink-0 text-v2-icon-icon-muted" />
</Show>
</button>
)}
</For>
</section>
</Show>
)}
</For>
<Show when={rows().length === 0}>
<div class="flex h-24 items-center justify-center text-[13px] font-[440] text-v2-text-text-muted">
{language.t("dialog.provider.empty")}
</div>
</Show>
</div>
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-10"
style={{ background: "linear-gradient(to bottom, transparent, var(--v2-background-bg-layer-01))" }}
/>
</div>
</div>
)
}
function ProviderConnection(props: { function ProviderConnection(props: {
provider: string provider: string
directory?: Accessor<string | undefined> directory?: Accessor<string | undefined>
@@ -396,8 +173,6 @@ function ProviderConnection(props: {
const serverSync = useServerSync() const serverSync = useServerSync()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const language = useLanguage() const language = useLanguage()
const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(props.directory) const providers = useProviders(props.directory)
const alive = { value: true } const alive = { value: true }
@@ -432,19 +207,11 @@ function ProviderConnection(props: {
) )
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
const cachedMethods = serverSync().data.provider_auth[props.provider]
const directMethod =
cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
const [store, setStore] = createStore({ const [store, setStore] = createStore({
methodIndex: directMethod as undefined | number, methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization, authorization: undefined as undefined | ProviderAuthAuthorization,
promptInputs: undefined as undefined | Record<string, string>, promptInputs: undefined as undefined | Record<string, string>,
state: (directMethod === undefined ? "pending" : undefined) as state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
| undefined
| "pending"
| "complete"
| "error"
| "prompt",
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
@@ -512,16 +279,6 @@ function ProviderConnection(props: {
return value.label ?? "" return value.label ?? ""
} }
const methodDetails = (value?: { type?: string; label?: string }) => {
const label = methodLabel(value)
const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i)
const hint = suffix?.[1]
return {
label: suffix ? label.slice(0, -suffix[0].length) : label,
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined,
}
}
function formatError(value: unknown, fallback: string): string { function formatError(value: unknown, fallback: string): string {
if (value && typeof value === "object" && "data" in value) { if (value && typeof value === "object" && "data" in value) {
const data = (value as { data?: { message?: unknown } }).data const data = (value as { data?: { message?: unknown } }).data
@@ -762,37 +519,6 @@ function ProviderConnection(props: {
props.setBack(goBack) props.setBack(goBack)
function MethodSelection() { function MethodSelection() {
if (newLayout())
return (
<div class="flex flex-col gap-2">
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
{language.t("provider.connect.selectMethod", { provider: provider().name })}
</div>
<div class="flex flex-col">
<For each={methods()}>
{(item, index) => {
const details = () => methodDetails(item)
return (
<button
type="button"
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => void selectMethod(index())}
>
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
</span>
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
<Show when={details().hint}>
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
</Show>
</button>
)
}}
</For>
</div>
</div>
)
return ( return (
<> <>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
@@ -826,18 +552,11 @@ function ProviderConnection(props: {
} }
function ApiAuthView() { function ApiAuthView() {
let apiKey: HTMLInputElement | undefined
const errorID = createUniqueId()
const [formStore, setFormStore] = createStore({ const [formStore, setFormStore] = createStore({
value: "", value: "",
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
onMount(() => {
if (!newLayout()) return
apiKey?.focus({ preventScroll: true })
})
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
@@ -862,58 +581,6 @@ function ProviderConnection(props: {
await complete() await complete()
} }
if (newLayout())
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<Show
when={provider().id === "opencode"}
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
>
<div class="flex flex-col gap-5">
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<Link
href="https://opencode.ai/zen"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.opencodeZen.visit.link")}
</Link>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
</Show>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
<TextInputV2
ref={apiKey}
class="!w-full"
name="apiKey"
placeholder={language.t("provider.connect.apiKey.placeholder")}
value={formStore.value}
invalid={formStore.error !== undefined}
aria-describedby={formStore.error ? errorID : undefined}
autocomplete="off"
spellcheck={false}
onInput={(event) => setFormStore("value", event.currentTarget.value)}
/>
</label>
<Show when={formStore.error}>
{(error) => (
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
{error()}
</div>
)}
</Show>
<ButtonV2 type="submit" variant="contrast">
{language.t("common.continue")}
</ButtonV2>
</form>
</div>
)
return ( return (
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<Switch> <Switch>
@@ -938,8 +605,7 @@ function ProviderConnection(props: {
</Switch> </Switch>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField <TextField
autofocus={!newLayout()} autofocus
ref={apiKey}
type="text" type="text"
label={language.t("provider.connect.apiKey.label", { provider: provider().name })} label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
placeholder={language.t("provider.connect.apiKey.placeholder")} placeholder={language.t("provider.connect.apiKey.placeholder")}
@@ -958,18 +624,11 @@ function ProviderConnection(props: {
} }
function OAuthCodeView() { function OAuthCodeView() {
let codeInput: HTMLInputElement | undefined
const errorID = createUniqueId()
const [formStore, setFormStore] = createStore({ const [formStore, setFormStore] = createStore({
value: "", value: "",
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
onMount(() => {
if (!newLayout()) return
codeInput?.focus({ preventScroll: true })
})
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
@@ -998,46 +657,6 @@ function ProviderConnection(props: {
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid"))) setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
} }
if (newLayout())
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")}
</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
<TextInputV2
ref={codeInput}
class="!w-full"
name="code"
placeholder={language.t("provider.connect.oauth.code.placeholder")}
value={formStore.value}
invalid={formStore.error !== undefined}
aria-describedby={formStore.error ? errorID : undefined}
autocomplete="off"
spellcheck={false}
onInput={(event) => setFormStore("value", event.currentTarget.value)}
/>
</label>
<Show when={formStore.error}>
{(error) => (
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
{error()}
</div>
)}
</Show>
<ButtonV2 type="submit" variant="contrast">
{language.t("common.continue")}
</ButtonV2>
</form>
</div>
)
return ( return (
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
@@ -1047,8 +666,7 @@ function ProviderConnection(props: {
</div> </div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField <TextField
autofocus={!newLayout()} autofocus
ref={codeInput}
type="text" type="text"
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })} label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
placeholder={language.t("provider.connect.oauth.code.placeholder")} placeholder={language.t("provider.connect.oauth.code.placeholder")}
@@ -1120,19 +738,10 @@ function ProviderConnection(props: {
} }
return ( return (
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-3"}> <div class="flex flex-col gap-6 px-2.5 pb-3">
<div class={newLayout() ? "flex h-10 shrink-0 items-start gap-2 px-3" : "flex items-center gap-4 px-2.5"}> <div class="px-2.5 flex gap-4 items-center">
<ProviderIcon <ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
id={props.provider} <div class="text-16-medium text-text-strong">
class={newLayout() ? "mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" : "size-5 shrink-0 icon-strong-base"}
/>
<div
class={
newLayout()
? "text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base"
: "text-16-medium text-text-strong"
}
>
<Switch> <Switch>
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}> <Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
{language.t("provider.connect.title.anthropicProMax")} {language.t("provider.connect.title.anthropicProMax")}
@@ -1141,12 +750,8 @@ function ProviderConnection(props: {
</Switch> </Switch>
</div> </div>
</div> </div>
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-10"}> <div class="px-2.5 pb-10 flex flex-col gap-6">
<div <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
onKeyDown={handleKey}
tabIndex={newLayout() ? undefined : 0}
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
>
<Switch> <Switch>
<Match when={loading()}> <Match when={loading()}>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
@@ -40,7 +40,7 @@ export function DialogCustomProvider(props: Props) {
) )
} }
export function CustomProviderForm(props: { autofocus?: boolean } = {}) { export function CustomProviderForm() {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync() const serverSync = useServerSync()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
@@ -192,7 +192,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<TextField <TextField
autofocus={props.autofocus ?? true} autofocus
label={language.t("provider.custom.field.providerID.label")} label={language.t("provider.custom.field.providerID.label")}
placeholder={language.t("provider.custom.field.providerID.placeholder")} placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")} description={language.t("provider.custom.field.providerID.description")}
@@ -1,55 +0,0 @@
// @ts-nocheck
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createSignal, onMount } from "solid-js"
import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
const names = [
"MiMo V2.5 Free",
"Nemotron 3 Ultra Free",
"Deepseek V4 Flash Free",
"North Mini Code Free",
"Hy3 Free",
"Big Pickle",
]
function SelectModelWithoutProviders() {
const dialog = useDialog()
const models = names.map((name, index) => ({
id: name.toLowerCase().replaceAll(" ", "-"),
name,
provider: { id: "opencode", name: "OpenCode" },
cost: { input: 0, output: 0 },
limit: { context: 128_000 },
capabilities: {
reasoning: index !== 5,
input: { text: true, image: false, audio: false, video: false, pdf: false },
},
}))
const [current, setCurrent] = createSignal(models[2])
const model = {
list: () => models,
current,
set(value) {
setCurrent(models.find((item) => item.id === value?.modelID))
},
}
const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
onMount(open)
return (
<Button variant="secondary" onClick={open}>
Open select model dialog
</Button>
)
}
export default {
title: "App/Dialogs/Select Model",
id: "app-dialog-select-model",
}
export const WithoutProviders = {
render: () => <SelectModelWithoutProviders />,
}
@@ -1,26 +1,23 @@
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Tag } from "@opencode-ai/ui/v2/badge-v2" import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme } from "@opencode-ai/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js" import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ModelTooltip } from "./model-tooltip" import { ModelTooltip } from "./model-tooltip"
type ModelState = ReturnType<typeof useLocal>["model"] type ModelState = ReturnType<typeof useLocal>["model"]
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => { export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
const local = useLocal() const local = useLocal()
const model = props.model ?? local.model const model = props.model ?? local.model
const dialog = useDialog() const dialog = useDialog()
const theme = useTheme()
const directory = () => decode64(local.slug()) const directory = () => decode64(local.slug())
const providers = useProviders(directory) const providers = useProviders(directory)
const language = useLanguage() const language = useLanguage()
@@ -31,7 +28,6 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
}) })
const isFree = (item: ReturnType<ModelState["list"]>[number]) => const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0) item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
const freeModels = createMemo(() => model.list().filter(isFree))
const openProviders = (provider?: string) => { const openProviders = (provider?: string) => {
void import("./dialog-connect-provider").then((x) => { void import("./dialog-connect-provider").then((x) => {
@@ -66,109 +62,111 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
}) })
return ( return (
<DialogV2 <DialogV2 containerClass="!h-[min(calc(100vh_-_16px),480px)] !w-[min(calc(100vw_-_16px),560px)]">
fit
containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
>
<DialogHeader closeLabel={language.t("common.close")}> <DialogHeader closeLabel={language.t("common.close")}>
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle> <DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2"> <div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
<div ref={listEl} class="flex min-h-0 flex-col"> <DialogBody class="min-h-0 flex-1 gap-0">
<div class="flex w-full flex-col items-start pb-3"> <ScrollView class="min-h-0 flex-1 w-full">
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2"> <div ref={listEl} class="flex min-h-full flex-col">
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]"> <div class="flex h-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
{language.t("dialog.model.unpaid.freeModels.title")} <div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
</div> <div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
</div> {language.t("dialog.model.unpaid.freeModels.title")}
<For each={freeModels()}>
{(item) => (
<TooltipV2
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
value={
<ModelTooltip
model={{ ...item, name: displayModelName(item.name) }}
latest={item.latest}
free={isFree(item)}
v2
/>
}
>
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => selectModel(item)}
>
<span class="min-w-0 truncate">{displayModelName(item.name)}</span>
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
<Show when={item.latest}>
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
</Show>
<Show when={currentKey() === modelKey(item)}>
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
</TooltipV2>
)}
</For>
</div>
<div class="flex w-full flex-col">
<div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
<div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.addMore.title")}
</div> </div>
</div> </div>
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2"> <For each={model.list()}>
<For {(item) => (
each={[...providers.popular()] <TooltipV2
.filter((provider) => featuredProviders.includes(provider.id)) class="w-full"
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))} placement="right-start"
> gutter={6}
{(provider) => ( openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
>
<button <button
type="button" type="button"
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none" class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
classList={{ onClick={() => selectModel(item)}
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
theme.mode() !== "dark",
"border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
}}
onClick={() => openProviders(provider.id)}
> >
<ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" /> <span class="min-w-0 truncate">{item.name}</span>
<span class="flex min-w-0 flex-col"> <Show when={isFree(item)}>
<span class="truncate">{provider.name}</span> <Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}> </Show>
<span class="truncate font-[440] text-v2-text-text-muted"> <Show when={item.latest}>
{language.t( <Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
provider.id === "opencode" </Show>
? "dialog.provider.opencode.tagline" <Show when={currentKey() === modelKey(item)}>
: "dialog.provider.opencodeGo.tagline", <Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
)} </Show>
</button>
</TooltipV2>
)}
</For>
</div>
<div class="flex w-full flex-col p-2.5 pt-0">
<div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
<div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.addMore.title")}
</div>
</div>
<div class="flex w-full flex-col">
<For
each={[...providers.popular()].sort((a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
}
return a.name.localeCompare(b.name)
})}
>
{(provider) => (
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => openProviders(provider.id)}
>
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{provider.name}</span>
<Show when={provider.id === "opencode"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.opencode.tagline")}
</span>
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={provider.id === "opencode-go"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.opencodeGo.tagline")}
</span>
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={provider.id === "anthropic"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.anthropic.note")}
</span> </span>
</Show> </Show>
</span> </button>
</button> )}
)} </For>
</For> <button
<button type="button"
type="button" class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none" onClick={() => openProviders()}
onClick={() => openProviders()} >
> <span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
{language.t("dialog.model.unpaid.viewMoreProviders")} <Icon name="dot-grid" size="small" />
</button> </span>
<span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.viewAll")}
</span>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </ScrollView>
</DialogBody> </DialogBody>
</DialogV2> </DialogV2>
) )
+68 -72
View File
@@ -1,35 +1,77 @@
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { createSignal, Show } from "solid-js" import { createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer" import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4" import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png" import { Persist, persisted } from "@/utils/persist"
import tabsImage from "@/assets/help/tabs.png"
const helpIcon = (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
data-slot="icon-svg"
>
<path
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
fill="var(--v2-icon-icon-base)"
/>
</svg>
)
const triggerClass =
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
// TODO: wire to changelog / seen-state when available // TODO: wire to changelog / seen-state when available
const showPopover = () => true const showPopover = () => true
export function HelpButton() {
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
const platform = usePlatform()
return (
<a
href="https://opencode.ai"
aria-label="Open the OpenCode website"
data-component="icon-button-v2"
data-size="large"
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
onClick={(event) => {
event.preventDefault()
platform.openLink(event.currentTarget.href)
}}
>
{helpIcon}
</a>
)
}
// can remove this after the tabs rollout has been out for a while // can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() { export function TabsInfoPopup() {
const settings = useSettings() if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
const platform = usePlatform()
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
// setState({ dismissed: false }) // for testing
const [drawerOpen, setDrawerOpen] = createSignal(false) const [drawerOpen, setDrawerOpen] = createSignal(false)
const windows = () => platform.platform === "desktop" && platform.os === "windows"
return ( return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right"> <Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Show when={settings.general.shouldDisplayTabsToast()}> <Show when={!state.dismissed}>
<div <div
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]" 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 <button
type="button" type="button"
aria-label="Dismiss Tabs information" 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)]" 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 <svg
width="16" width="16"
@@ -46,7 +88,7 @@ export function TabsInfoPopup() {
type="button" type="button"
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left" class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
onClick={() => { onClick={() => {
settings.general.dismissTabsToast() setState("dismissed", true)
setDrawerOpen(true) setDrawerOpen(true)
}} }}
> >
@@ -65,82 +107,36 @@ export function TabsInfoPopup() {
Introducing Tabs Introducing Tabs
</p> </p>
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]"> <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> </p>
</div> </div>
</button> </button>
</div> </div>
</Show> </Show>
<DrawerContent <DrawerContent>
style={ <div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
windows() <p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
? { June 16
inset: "0 0 0 auto", </p>
"max-height": "100vh",
"max-width": "100vw",
"border-radius": "0",
}
: undefined
}
>
<Show when={windows()}>
<DrawerClose <DrawerClose
as={IconButtonV2} as={IconButtonV2}
type="button" type="button"
size="small" size="small"
variant="neutral" variant="ghost-muted"
aria-label="Close" aria-label="Close"
icon={<IconV2 name="xmark-small" />} icon={<IconV2 name="xmark-small" />}
class="absolute top-[10px] left-[-36px]"
/> />
</Show>
<div
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
classList={{
"h-[40px] px-4": windows(),
"h-[52px] p-4": !windows(),
}}
>
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
July 14
</p>
<Show when={!windows()}>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="ghost-muted"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
/>
</Show>
</div> </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"> <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> </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> </div>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
@@ -1,591 +0,0 @@
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { Prompt, ReferenceInfo } from "@opencode-ai/sdk/v2/client"
import { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import type { PromptInputProps } from "@/components/prompt-input/contracts"
import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history"
import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store"
import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder"
import { createPromptSubmit } from "@/components/prompt-input/submit"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
import { useComments } from "@/context/comments"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePermission } from "@/context/permission"
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { createSessionTabs } from "@/pages/session/helpers"
import { showToast } from "@/utils/toast"
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
import {
createPromptInputV2Controller,
createPromptInputV2State,
type PromptInputV2Interaction,
} from "@opencode-ai/session-ui/v2/prompt-input/interaction"
export type PromptInputV2ComposerProps = {
class?: string
controller: PromptInputV2ComposerController
borderUnderlay?: boolean
edit?: PromptInputProps["edit"]
onEditLoaded?: PromptInputProps["onEditLoaded"]
}
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
readonly model: PromptInputProps["controls"]["model"]
}
export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
const dialog = useDialog()
const command = useCommand()
const language = useLanguage()
useCommands(props)
useEditHandler(props)
return (
<div class="flex flex-col gap-3">
<PromptInputV2
controller={props.controller}
borderUnderlay={props.borderUnderlay}
class={props.class}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
modelControl={
<PromptInputV2ModelControl
loading={props.controller.model.loading}
paid={props.controller.model.paid}
title={language.t("command.model.choose")}
keybind={command.keybindParts("model.choose")}
model={props.controller.model.selection}
providerID={props.controller.model.selection.current()?.provider?.id}
modelName={props.controller.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
onClose={props.controller.restoreFocus}
onUnpaidClick={() =>
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controller.model.selection} />)
}
/>
}
/>
</div>
)
}
const useEditHandler = (props: PromptInputV2ComposerProps) => {
const prompt = usePrompt()
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
edit.context.forEach((item) =>
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
}),
)
props.controller.dispatch({ type: "mode.normal" })
props.controller.resetHistory()
prompt.set(edit.prompt, promptLength(edit.prompt))
props.controller.restoreFocus()
props.onEditLoaded?.()
},
{ defer: true },
),
)
}
const useCommands = (props: PromptInputV2ComposerProps) => {
const command = useCommand()
const language = useLanguage()
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: props.controller.state.mode !== "normal",
onSelect: () => props.controller.attach(),
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: "mod+shift+x",
disabled: props.controller.state.mode === "shell",
onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: "mod+shift+e",
disabled: props.controller.state.mode === "normal",
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
},
])
}
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
const sdk = useSDK()
const sync = useSync()
const files = useFile()
const layout = useLayout()
const comments = useComments()
const dialog = useDialog()
const command = useCommand()
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
const prompt = props.state ?? usePrompt()
let editor: HTMLDivElement | undefined
const interaction = createPromptInputV2State()
const mode = () => interaction[0].mode
const history = props.history ?? createPersistedPromptInputHistory()
const tabs = () => props.controls.session.tabs
const activeFileTab = createSessionTabs({
tabs,
pathFromTab: files.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
}).activeFileTab
const recent = createMemo(() => {
const all = tabs().all()
const active = activeFileTab()
const order = active ? [active, ...all.filter((tab) => tab !== active)] : all
return order.reduce<string[]>((result, tab) => {
const path = files.pathFromTab(tab)
if (!path || result.includes(path)) return result
return [...result, path]
}, [])
})
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
const attachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
const commentCount = createMemo(() => {
if (mode() === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
})
const blank = createMemo(() => {
const text = prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
})
const stopping = createMemo(() => working() && blank())
const placeholder = createMemo(() =>
promptPlaceholder({
mode: mode(),
commentCount: commentCount(),
example: mode() === "shell" ? "git status" : "",
suggest: false,
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
)
const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder())
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
return prompt.context.items().flatMap((item) => {
const comment = item.comment?.trim()
if (!comment) return []
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
const nextSelection =
selection ??
(item.selection
? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange)
: undefined)
if (!nextSelection) return []
return [
{
id: item.commentID ?? item.key,
path: item.path,
selection: { ...nextSelection },
comment,
time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
origin: item.commentOrigin,
preview: item.preview,
} satisfies PromptHistoryComment,
]
})
}
const restoreHistoryComments = (items: PromptHistoryComment[]) => {
comments.replace(
items.map((item) => ({
id: item.id,
file: item.path,
selection: { ...item.selection },
comment: item.comment,
time: item.time,
})),
)
prompt.context.replaceComments(
items.map((item) => ({
type: "file",
path: item.path,
selection: selectionFromLines(item.selection),
comment: item.comment,
commentID: item.id,
commentOrigin: item.origin,
preview: item.preview,
})),
)
}
const accepting = createMemo(() => {
const id = props.controls.session.id
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
return permission.isAutoAccepting(id, sdk().directory)
})
const submission = createPromptSubmit({
prompt,
info,
imageAttachments: attachments,
commentCount,
autoAccept: accepting,
mode,
working,
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
promptLength,
addToHistory: (value, mode) => controller.addHistory(value, mode),
resetHistoryNavigation: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
setPopover: (popover) => {
if (!popover) controller.dispatch({ type: "popover.close" })
},
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
model: props.controls.model.selection,
})
const referenceDescription = (reference: ReferenceInfo) =>
reference.source.type === "git" ? reference.source.repository : reference.source.path
const references = createMemo(() =>
sync()
.data.reference.filter((reference) => !reference.hidden)
.map((reference) => ({
id: `reference:${reference.name}`,
kind: "reference" as const,
label: `@${reference.name}`,
path: reference.path,
description: reference.description ?? referenceDescription(reference),
mention: {
type: "file" as const,
path: reference.path,
content: `@${reference.name}`,
start: 0,
end: 0,
mime: "application/x-directory",
filename: reference.name,
},
})),
)
const resources = createMemo(() =>
Object.values(sync().data.mcp_resource).map((resource) => ({
id: `resource:${resource.client}:${resource.uri}`,
kind: "resource" as const,
label: `@${resource.name}`,
path: resource.uri,
description: resource.description,
mention: {
type: "file" as const,
path: resource.uri,
content: `@${resource.name}`,
start: 0,
end: 0,
mime: resource.mimeType ?? "text/plain",
filename: resource.name,
url: resource.uri,
source: {
type: "resource" as const,
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
clientName: resource.client,
uri: resource.uri,
},
},
resource,
})),
)
const context = createMemo<PromptInputV2Suggestion[]>(() => [
...references(),
...props.controls.agents.available
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent) => ({
id: `agent:${agent.name}`,
kind: "agent" as const,
label: `@${agent.name}`,
mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 },
})),
...resources(),
...recent().map((path) => ({
id: `file:${path}`,
kind: "file" as const,
label: path,
path,
recent: true,
mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 },
})),
])
const slashCommands = createMemo(() => [
...sync().data.command.map((item) => ({
id: `custom.${item.name}`,
trigger: item.name,
title: item.name,
description: item.description,
type: "custom" as const,
})),
...command.options
.filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash)
.map((item) => ({
id: item.id,
trigger: item.slash!,
title: item.title,
description: item.description,
type: "builtin" as const,
})),
])
const commands = createMemo<PromptInputV2Suggestion[]>(() =>
slashCommands().map((item) => ({
id: item.id,
kind: "command",
label: `/${item.trigger}`,
trigger: item.trigger,
title: item.title,
description: item.description,
keybind: command.keybindParts(item.id),
})),
)
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
const controller = createPromptInputV2Controller({
store: () => prompt.capture().store,
state: interaction,
identity: () => prompt.capture(),
history: {
entries: (mode) =>
history.entries(mode).map((value) => {
const entry = normalizePromptHistoryEntry(value)
return { prompt: entry.prompt, metadata: entry.comments }
}),
add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
capture: historyComments,
restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
},
commands,
context,
searchContextFiles: async (query) =>
(await files.searchFilesAndDirectories(query)).map((path) => ({
id: `file:${path}`,
kind: "file",
label: path,
path,
mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 },
})),
onContextRemove(item) {
if (item?.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
openContext(key) {
const item = controller.contextItem(key)
if (item) openComment(item, props, sync, layout, files, comments)
},
onEditor(element) {
editor = element as HTMLDivElement
props.ref?.(editor)
},
onSuggestionSelect(item) {
if (item.kind !== "command") return
const selected = slashCommands().find((entry) => entry.id === item.id)
if (!selected || selected.type === "custom") return
return () => command.trigger(selected.id, "slash")
},
attachments: {
picker: platform.openAttachmentPickerDialog,
directory: () => sdk().directory,
isDialogActive: () => !!dialog.active,
warn: () =>
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
}),
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
},
view: {
placeholder: designPlaceholder,
agent:
props.controls.agents.visible && props.controls.agents.options.length > 0
? {
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
current: () => props.controls.agents.current,
onSelect: props.controls.agents.select,
keybind: () => command.keybindParts("agent.cycle"),
}
: undefined,
variant: {
options: () => variants().map((value) => ({ id: value, label: value })),
current: () => props.controls.model.selection.variant.current() ?? "default",
onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
keybind: () => command.keybindParts("model.variant.cycle"),
},
submit: {
stopping,
working,
onSubmit: () => void submission.handleSubmit(new Event("submit")),
onStop: () => void submission.abort(),
},
},
})
Object.defineProperty(controller, "model", { get: () => props.controls.model })
return controller as PromptInputV2ComposerController
}
function PromptInputV2ModelControl(props: {
loading: boolean
paid: boolean
title: string
keybind: string[]
model: PromptInputV2ComposerController["model"]["selection"]
providerID?: string
modelName: string
onClose: () => void
onUnpaidClick: () => void
}) {
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
const content = () => (
<>
<Show when={props.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate leading-4">{props.modelName}</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
<Icon name="chevron-down" />
</span>
</>
)
return (
<Show when={!props.loading}>
<TooltipV2
placement="top"
gutter={4}
value={
<>
{props.title}
<KeybindV2 keys={props.keybind} variant="neutral" />
</>
}
>
<Show
when={props.paid}
fallback={
<ButtonV2
data-action="prompt-model"
variant="ghost-muted"
size="normal"
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": shouldAnimate() }}
style={{ height: "28px" }}
onClick={props.onUnpaidClick}
>
{content()}
</ButtonV2>
}
>
<ModelSelectorPopoverV2
model={props.model}
triggerAs={ButtonV2}
triggerProps={{
variant: "ghost-muted",
size: "normal",
style: { height: "28px" },
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
classList: { "animate-in fade-in": shouldAnimate() },
"data-action": "prompt-model",
}}
onClose={props.onClose}
>
{content()}
</ModelSelectorPopoverV2>
</Show>
</TooltipV2>
</Show>
)
}
function openComment(
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
props: PromptInputV2ControllerProps,
sync: ReturnType<typeof useSync>,
layout: ReturnType<typeof useLayout>,
files: ReturnType<typeof useFile>,
comments: ReturnType<typeof useComments>,
) {
if (!item.commentID) return
const focus = { file: item.path, id: item.commentID }
comments.setActive(focus)
const queueFocus = (attempts = 6) => {
requestAnimationFrame(() => {
comments.setFocus({ ...focus })
if (attempts <= 0) return
requestAnimationFrame(() => {
const current = comments.focus()
if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1)
})
})
}
const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
const review =
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
if (review) {
layout.fileTree.setTab("changes")
props.controls.session.tabs.setActive("review")
queueFocus()
return
}
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
void props.controls.session.tabs.open(tab)
props.controls.session.tabs.setActive(tab)
void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
}
@@ -30,16 +30,8 @@ function PromptInputExample() {
activeTab: undefined as string | undefined, activeTab: undefined as string | undefined,
reviewOpen: false, reviewOpen: false,
}) })
const storyModel = {
id: "claude-3-7-sonnet",
name: "Claude 3.7 Sonnet",
provider: { id: "anthropic", name: "Anthropic" },
}
const model = { const model = {
current: () => storyModel, current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
list: () => [storyModel],
visible: () => true,
set: () => {},
variant: { variant: {
list: () => ["fast", "thinking"], list: () => ["fast", "thinking"],
current: () => controls.variant, current: () => controls.variant,
@@ -75,6 +67,7 @@ function PromptInputExample() {
open: () => setControls("reviewOpen", true), open: () => setControls("reviewOpen", true),
}, },
}, },
newLayoutDesigns: true,
} }
const addReviewComment = () => { const addReviewComment = () => {
const comment = controls.comments + 1 const comment = controls.comments + 1
@@ -153,6 +146,7 @@ function PromptInputWithOpenDock() {
}, },
reviewPanel: { opened: () => false, open: () => {} }, reviewPanel: { opened: () => false, open: () => {} },
}, },
newLayoutDesigns: true,
} }
const state = { const state = {
blocked: () => false, blocked: () => false,
File diff suppressed because it is too large Load Diff
@@ -38,7 +38,7 @@ type PromptAttachmentsCoreInput = {
getPathForFile?: (file: File) => string getPathForFile?: (file: File) => string
} }
export type PromptAttachmentsInput = { type PromptAttachmentsInput = {
prompt: ReturnType<typeof usePrompt> prompt: ReturnType<typeof usePrompt>
editor: () => HTMLDivElement | undefined editor: () => HTMLDivElement | undefined
isDialogActive: () => boolean isDialogActive: () => boolean
@@ -89,15 +89,13 @@ const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID:
} }
export function buildRequestParts(input: BuildRequestPartsInput) { export function buildRequestParts(input: BuildRequestPartsInput) {
const requestParts: PromptRequestPart[] = input.text.trim() const requestParts: PromptRequestPart[] = [
? [ {
{ id: Identifier.ascending("part"),
id: Identifier.ascending("part"), type: "text",
type: "text", text: input.text,
text: input.text, },
}, ]
]
: []
const files = input.prompt.filter(isFileAttachment).map((attachment) => { const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path) const path = absolute(input.sessionDirectory, attachment.path)
@@ -1,57 +0,0 @@
import type { useLocal } from "@/context/local"
import type { Prompt, usePrompt } from "@/context/prompt"
import type { PromptInputHistory } from "./history-store"
import type { FollowupDraft } from "./submit"
export type PromptInputState = ReturnType<typeof usePrompt>
export type PromptInputSubmission = {
abort: () => Promise<void> | void
handleSubmit: (event: Event) => Promise<void> | void
}
export type PromptInputControls = {
agents: {
available: { name: string; hidden?: boolean; mode: string }[]
options: string[]
current: string
loading: boolean
visible: boolean
select: (name: string | undefined) => void
}
model: {
selection: ReturnType<typeof useLocal>["model"]
paid: boolean
loading: boolean
}
session: {
id?: string
tabs: {
active: () => string | undefined
all: () => string[]
open: (tab: string) => void | Promise<void>
setActive: (tab: string) => void
}
reviewPanel: {
opened: () => boolean
open: () => void
}
}
}
export interface PromptInputProps {
class?: string
state?: PromptInputState
history?: PromptInputHistory
submission?: PromptInputSubmission
controls: PromptInputControls
ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
onEditLoaded?: () => void
shouldQueue?: () => boolean
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
}
@@ -1,47 +0,0 @@
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { Prompt } from "@/context/prompt"
import { Persist, persisted } from "@/utils/persist"
import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history"
export type PromptInputHistory = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
}
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
function createPromptInputHistoryStore(
normal: Store<PromptHistoryState>,
setNormal: SetStoreFunction<PromptHistoryState>,
shell: Store<PromptHistoryState>,
setShell: SetStoreFunction<PromptHistoryState>,
): PromptInputHistory {
return {
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
add(prompt, mode, comments) {
const current = mode === "shell" ? shell : normal
const setCurrent = mode === "shell" ? setShell : setNormal
const next = prependHistoryEntry(current.entries, prompt, comments)
if (next === current.entries) return
setCurrent("entries", next)
},
}
}
export function createPromptInputHistory(): PromptInputHistory {
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}
export function createPersistedPromptInputHistory() {
const [normal, setNormal] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<PromptHistoryState>({ entries: [] }),
)
const [shell, setShell] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<PromptHistoryState>({ entries: [] }),
)
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}
@@ -13,8 +13,3 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
if (!input.suggest) return input.t("prompt.placeholder.simple") if (!input.suggest) return input.t("prompt.placeholder.simple")
return input.t("prompt.placeholder.normal", { example: input.example }) return input.t("prompt.placeholder.normal", { example: input.example })
} }
export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) {
if (mode === "shell") return placeholder
return "Ask anything, / for commands, @ for context..."
}
@@ -1,13 +1,12 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { createStore } from "solid-js/store" import type { Prompt } from "@/context/prompt"
import type { Prompt, PromptStore } from "@/context/prompt"
import type { ModelSelection } from "@/context/local" import type { ModelSelection } from "@/context/local"
let createPromptSubmit: typeof import("./submit").createPromptSubmit let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = [] const createdClients: string[] = []
const createdSessions: string[] = [] const createdSessions: string[] = []
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = [] const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
const optimistic: Array<{ const optimistic: Array<{
directory?: string directory?: string
sessionID?: string sessionID?: string
@@ -28,17 +27,9 @@ let params: { id?: string } = {}
let search: { draftId?: string } = {} let search: { draftId?: string } = {}
let selected = "/repo/worktree-a" let selected = "/repo/worktree-a"
let variant: string | undefined 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 promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({
prompt: promptValue,
cursor: 0,
context: { items: [] },
})
const prompt = { const prompt = {
store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore],
ready: Object.assign(() => true, { promise: Promise.resolve(true) }), ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
current: () => promptValue, current: () => promptValue,
cursor: () => 0, cursor: () => 0,
@@ -65,7 +56,6 @@ const clientFor = (directory: string) => {
return { return {
session: { session: {
create: async () => { create: async () => {
await createSessionGate
createdSessions.push(directory) createdSessions.push(directory)
return { return {
data: { data: {
@@ -132,14 +122,13 @@ beforeAll(async () => {
}), }),
})) }))
mock.module("@/context/permission", () => { mock.module("@/context/permission", () => ({
const state = (server: string) => ({ usePermission: () => ({
enableAutoAccept(sessionID: string, directory: string) { enableAutoAccept(sessionID: string, directory: string) {
enabledAutoAccept.push({ server, sessionID, directory }) enabledAutoAccept.push({ sessionID, directory })
}, },
}) }),
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) } }))
})
mock.module("@/context/server", () => ({ mock.module("@/context/server", () => ({
useServer: () => ({ key: "server-key" }), useServer: () => ({ key: "server-key" }),
@@ -262,8 +251,6 @@ beforeEach(() => {
syncedDirectories.length = 0 syncedDirectories.length = 0
selected = "/repo/worktree-a" selected = "/repo/worktree-a"
variant = undefined variant = undefined
permissionServer = "server-a"
createSessionGate = undefined
for (const key of Object.keys(storedSessions)) delete storedSessions[key] for (const key of Object.keys(storedSessions)) delete storedSessions[key]
}) })
@@ -331,40 +318,7 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event) await submit.handleSubmit(event)
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }]) expect(enabledAutoAccept).toEqual([{ 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" }])
}) })
test("promotes drafts using the selected project's server", async () => { test("promotes drafts using the selected project's server", async () => {
@@ -315,7 +315,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.resetHistoryNavigation() input.resetHistoryNavigation()
const projectDirectory = sdk().directory const projectDirectory = sdk().directory
const permissionState = permission.currentServerState()
const isNewSession = !params.id const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept() const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main" const worktreeSelection = input.newSessionWorktree?.() || "main"
@@ -379,7 +378,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
session = created session = created
await startTransition(() => { await startTransition(() => {
if (!session) return if (!session) return
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory) if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id, { local.session.promote(sessionDirectory, session.id, {
agent: currentAgent.name, agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.id }, model: { providerID: currentModel.provider.id, modelID: currentModel.id },
@@ -12,6 +12,7 @@ export type PromptInputTransientState = {
draggingType: "image" | "@mention" | null draggingType: "image" | "@mention" | null
mode: "normal" | "shell" mode: "normal" | "shell"
applyingHistory: boolean applyingHistory: boolean
variantOpen: boolean
} }
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) { function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
@@ -24,6 +25,7 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
draggingType: null, draggingType: null,
mode: "normal", mode: "normal",
applyingHistory: false, applyingHistory: false,
variantOpen: false,
}) })
} }
@@ -38,6 +40,7 @@ export function createPromptInputTransientState(identity: Accessor<unknown>, pla
draggingType: null, draggingType: null,
mode: "normal", mode: "normal",
applyingHistory: false, applyingHistory: false,
variantOpen: false,
}) })
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true })) createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
@@ -1,13 +1,4 @@
import { import { createEffect, For, onCleanup, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
createEffect,
createSignal,
For,
onCleanup,
Show,
splitProps,
type Accessor,
type ComponentProps,
} from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -55,7 +46,7 @@ export function createPromptProjectController(input: {
const [store, setStore] = createStore({ open: false, search: "", active: "" }) const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined let searchRef: HTMLInputElement | undefined
const current = () => { const selected = () => {
const key = pathKey(input.controls().directory) const key = pathKey(input.controls().directory)
return input return input
.controls() .controls()
@@ -65,7 +56,6 @@ export function createPromptProjectController(input: {
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)), (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
) )
} }
const selected = () => current() ?? input.controls().available[0]
const projects = () => { const projects = () => {
const search = store.search.trim().toLowerCase() const search = store.search.trim().toLowerCase()
if (!search) return input.controls().available if (!search) return input.controls().available
@@ -101,8 +91,8 @@ export function createPromptProjectController(input: {
} }
const select = (project: PromptProject) => { const select = (project: PromptProject) => {
if ( if (
pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") || pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") ||
project.server?.key !== current()?.server?.key project.server?.key !== selected()?.server?.key
) { ) {
input.controls().select(project.worktree, project.server?.key) input.controls().select(project.worktree, project.server?.key)
} }
@@ -125,7 +115,6 @@ export function createPromptProjectController(input: {
return { return {
selected, selected,
empty: () => input.controls().available.length === 0,
projects, projects,
servers, servers,
projectKey, projectKey,
@@ -195,28 +184,9 @@ export function PromptProjectSelector(props: {
controller: PromptProjectController controller: PromptProjectController
placement?: "bottom" | "bottom-start" placement?: "bottom" | "bottom-start"
}) { }) {
const [triggerReady, setTriggerReady] = createSignal(false)
let contentRef: HTMLDivElement | undefined let contentRef: HTMLDivElement | undefined
let triggerFrame: number | undefined
let restoreTrigger = true 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 = () => const activeItem = () =>
props.controller.active() props.controller.active()
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`) ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
@@ -287,13 +257,13 @@ export function PromptProjectSelector(props: {
return ( return (
<DropdownMenu <DropdownMenu
open={triggerReady() && props.controller.open()} open={props.controller.open()}
placement={props.placement ?? "bottom"} placement={props.placement ?? "bottom"}
gutter={4} gutter={4}
modal={false} modal={false}
onOpenChange={(open) => props.controller.setOpen(open)} 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.Portal>
<DropdownMenu.Content <DropdownMenu.Content
ref={contentRef} ref={contentRef}
@@ -93,36 +93,24 @@ export function PromptWorkspaceSelector(props: {
</MenuV2.Content> </MenuV2.Content>
</MenuV2.Portal> </MenuV2.Portal>
</MenuV2> </MenuV2>
<PromptGitStatus branch={props.branch} /> <Show when={props.branch}>
{(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>
</>
)}
</Show>
</> </>
) )
} }
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
const language = useLanguage()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
return props.branch
}
return (
<Show when={label()}>
{(value) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
placement="top"
value={value()}
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">{value()}</span>
</div>
</TooltipV2>
</>
)}
</Show>
)
}
@@ -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 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="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}> <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 class="mt-8">{props.children}</div>
</div> </div>
</div> </div>
@@ -2,8 +2,7 @@ import { createMemo, Show } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import { useSortable } from "@dnd-kit/solid/sortable" import { useSortable } from "@dnd-kit/solid/sortable"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -20,7 +19,6 @@ export function SortableTabV2(props: {
const file = useFile() const file = useFile()
const language = useLanguage() const language = useLanguage()
const command = useCommand() const command = useCommand()
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
const sortable = useSortable({ const sortable = useSortable({
get id() { get id() {
return props.tab return props.tab
@@ -41,15 +39,9 @@ export function SortableTabV2(props: {
<Tabs.Trigger <Tabs.Trigger
value={props.tab} value={props.tab}
closeButton={ closeButton={
<TooltipV2 <TooltipKeybind
value={ title={language.t("common.closeTab")}
<> keybind={command.keybind("tab.close")}
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<KeybindV2 keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom" placement="bottom"
gutter={10} gutter={10}
> >
@@ -60,7 +52,7 @@ export function SortableTabV2(props: {
onClick={() => props.onTabClose(props.tab)} onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")} aria-label={language.t("common.closeTab")}
/> />
</TooltipV2> </TooltipKeybind>
} }
hideCloseButton hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)} onMiddleClick={() => props.onTabClose(props.tab)}
@@ -65,12 +65,9 @@ export function SortableTerminalTabV2(props: {
const focus = () => { const focus = () => {
if (store.editing) return if (store.editing) return
terminal.requestFocus(props.terminal.id)
terminal.open(props.terminal.id) terminal.open(props.terminal.id)
if (document.activeElement instanceof HTMLElement) document.activeElement.blur() if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
focusTerminalById(props.terminal.id) 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) => { const edit = (e?: Event) => {
@@ -379,6 +379,7 @@ export const SettingsGeneral: Component = () => {
/> />
</div> </div>
</SettingsRow> </SettingsRow>
</SettingsList> </SettingsList>
</div> </div>
) )
@@ -10,7 +10,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command" import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
@@ -20,6 +20,7 @@ const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette" const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt" type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
@@ -133,10 +133,9 @@
} }
[data-slot="settings-v2-row-description"] { [data-slot="settings-v2-row-description"] {
margin-block: -3.5px;
font-size: 13px; font-size: 13px;
font-weight: 440; font-weight: 440;
line-height: 20px; line-height: 1;
color: var(--v2-text-text-muted); color: var(--v2-text-text-muted);
} }
@@ -271,10 +270,10 @@
} }
.settings-v2-provider-description { .settings-v2-provider-description {
margin-block: -3.5px; margin: 0;
font-size: 13px; font-size: 13px;
font-weight: 440; font-weight: 440;
line-height: 20px; line-height: 1;
color: var(--v2-text-text-muted); color: var(--v2-text-text-muted);
} }
@@ -372,7 +371,7 @@
transform: translateY(-50%); transform: translateY(-50%);
} }
.settings-v2-models { .settings-v2-tab-body.settings-v2-models {
gap: 24px; gap: 24px;
} }
@@ -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 { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global" 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 Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody }))) const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@@ -20,14 +19,16 @@ export function StatusPopover() {
const global = useGlobal() const global = useGlobal()
const sync = useSync() const sync = useSync()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) const mcpIssue = createMemo(() => {
const issue = createMemo(() => const mcp = Object.values(sync().data.mcp ?? {})
hasNonBlockingServiceIssue({ const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), const warn = mcp.some((item) => item.status === "needs_auth")
lsp: (sync().data.lsp ?? []).map((item) => item.status), 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 ( return (
<Popover <Popover
@@ -46,11 +47,13 @@ export function StatusPopover() {
<Icon name={shown() ? "status-active" : "status"} size="small" /> <Icon name={shown() ? "status-active" : "status"} size="small" />
</div> </div>
<div <div
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({ classList={{
ready: ready(), "absolute -top-px -right-px size-1.5 rounded-full": true,
serverHealth: serverHealth(), "bg-icon-success-base": ready() && healthy(),
issue: issue(), "bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
})}`} "bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
"bg-border-weak-base": serverHealthy() || !ready(),
}}
/> />
</div> </div>
} }
@@ -84,18 +87,21 @@ function DirectoryStatusPopover() {
const sync = useSync() const sync = useSync()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
const issue = createMemo(() => const mcpIssue = createMemo(() => {
hasNonBlockingServiceIssue({ const mcp = Object.values(sync().data.mcp ?? {})
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
lsp: (sync().data.lsp ?? []).map((item) => item.status), 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>(() => ({ const state = createMemo<StatusPopoverState>(() => ({
shown: shown(), shown: shown(),
ready: ready(), ready: ready(),
healthy: healthy(),
serverHealth: serverHealth(), serverHealth: serverHealth(),
issue: issue(), issue: mcpIssue(),
label: language.t("status.popover.trigger"), label: language.t("status.popover.trigger"),
onOpenChange: setShown, onOpenChange: setShown,
body: () => ( body: () => (
@@ -117,8 +123,8 @@ function ServerStatusPopover() {
const state = createMemo<StatusPopoverState>(() => ({ const state = createMemo<StatusPopoverState>(() => ({
shown: shown(), shown: shown(),
ready: serverHealth() !== undefined, ready: serverHealth() !== undefined,
healthy: serverHealth() === true,
serverHealth: serverHealth(), serverHealth: serverHealth(),
issue: false,
label: language.t("status.popover.trigger"), label: language.t("status.popover.trigger"),
onOpenChange: setShown, onOpenChange: setShown,
body: () => ( body: () => (
@@ -134,8 +140,9 @@ function ServerStatusPopover() {
type StatusPopoverState = { type StatusPopoverState = {
shown: boolean shown: boolean
ready: boolean ready: boolean
healthy: boolean
serverHealth: boolean | undefined serverHealth: boolean | undefined
issue: boolean issue?: "critical" | "warning"
label: string label: string
onOpenChange: (value: boolean) => void onOpenChange: (value: boolean) => void
body: () => JSX.Element body: () => JSX.Element
@@ -154,6 +161,16 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
} }
function StatusPopoverView(props: { state: StatusPopoverState }) { 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 = { const popoverProps = {
class: class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl", "[&_[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"> <div class="relative size-4">
<IconV2 name={props.state.shown ? "status-active" : "status"} /> <IconV2 name={props.state.shown ? "status-active" : "status"} />
<div <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> </div>
} }
+3 -37
View File
@@ -23,7 +23,6 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
export interface TerminalProps extends ComponentProps<"div"> { export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY pty: LocalPTY
autoFocus?: boolean autoFocus?: boolean
onAutoFocus?: () => void
onSubmit?: () => void onSubmit?: () => void
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
onConnect?: () => void onConnect?: () => void
@@ -186,15 +185,7 @@ export const Terminal = (props: TerminalProps) => {
const authToken = connection.type === "http" ? connection.authToken : false const authToken = connection.type === "http" ? connection.authToken : false
const sameOrigin = new URL(url, location.href).origin === location.origin const sameOrigin = new URL(url, location.href).origin === location.origin
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, [ const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
"pty",
"class",
"classList",
"autoFocus",
"onAutoFocus",
"onConnect",
"onConnectError",
])
const id = local.pty.id const id = local.pty.id
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize = const restoreSize =
@@ -292,12 +283,7 @@ export const Terminal = (props: TerminalProps) => {
const scheduleSize = (cols: number, rows: number) => { const scheduleSize = (cols: number, rows: number) => {
if (disposed) return if (disposed) return
if (lastSize?.cols === cols && lastSize?.rows === rows) { if (lastSize?.cols === cols && lastSize?.rows === rows) return
pendingSize = undefined
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
sizeTimer = undefined
return
}
pendingSize = { cols, rows } pendingSize = { cols, rows }
@@ -322,10 +308,8 @@ export const Terminal = (props: TerminalProps) => {
createEffect(() => { createEffect(() => {
const colors = terminalColors() const colors = terminalColors()
const mode = theme.mode() === "dark" ? "dark" : "light"
if (!term) return if (!term) return
setOptionIfSupported(term, "theme", colors) setOptionIfSupported(term, "theme", colors)
setOptionIfSupported(term, "colorScheme", mode)
}) })
createEffect(() => { createEffect(() => {
@@ -403,7 +387,6 @@ export const Terminal = (props: TerminalProps) => {
} }
_ghostty = g _ghostty = g
term = t term = t
setOptionIfSupported(t, "colorScheme", theme.mode() === "dark" ? "dark" : "light")
output = terminalWriter((data, done) => output = terminalWriter((data, done) =>
t.write(data, () => { t.write(data, () => {
done?.() done?.()
@@ -433,7 +416,6 @@ export const Terminal = (props: TerminalProps) => {
fitAddon = fit fitAddon = fit
serializeAddon = serializer serializeAddon = serializer
const active = document.activeElement
t.open(container) t.open(container)
useTerminalUiBindings({ useTerminalUiBindings({
container, container,
@@ -443,22 +425,7 @@ export const Terminal = (props: TerminalProps) => {
handleLinkClick, handleLinkClick,
}) })
if (local.autoFocus === true) { if (local.autoFocus !== false) focusTerminal()
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 (typeof document !== "undefined" && document.fonts) { if (typeof document !== "undefined" && document.fonts) {
void document.fonts.ready.then(scheduleFit) void document.fonts.ready.then(scheduleFit)
@@ -603,7 +570,6 @@ export const Terminal = (props: TerminalProps) => {
tries = 0 tries = 0
local.onConnect?.() local.onConnect?.()
scheduleSize(t.cols, t.rows) scheduleSize(t.cols, t.rows)
if (t.getMode(2031)) t.write("\x1b[?996n")
} }
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {
+7 -22
View File
@@ -67,7 +67,7 @@ export function useTitlebarRightMount() {
return mount return mount
} }
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) { export function Titlebar(props: { update?: TitlebarUpdate }) {
const layout = useLayout() const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
const command = useCommand() const command = useCommand()
@@ -392,7 +392,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
id: "tab.new", id: "tab.new",
category: "tab", category: "tab",
title: language.t("command.session.new"), title: language.t("command.session.new"),
keybind: "mod+t,mod+n", keybind: "mod+t",
hidden: true, hidden: true,
onSelect: openNewTab, onSelect: openNewTab,
}, },
@@ -462,7 +462,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
"md:pl-4": !mac(), "md:pl-4": !mac(),
}} }}
> >
<ChannelIndicator debugTools={props.debugTools} /> <ChannelIndicator />
<Show when={windows() || linux()}> <Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} variant="v2" /> <WindowsAppMenu command={command} platform={platform} variant="v2" />
</Show> </Show>
@@ -660,9 +660,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
</div> </div>
</Show> </Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" /> <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<ChannelIndicator />
</div> </div>
</div> </div>
<ChannelIndicator debugTools={props.debugTools} />
</div> </div>
</div> </div>
@@ -747,27 +747,12 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
) )
} }
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) { function ChannelIndicator() {
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
if (channel === "dev" && props.debugTools) {
return (
<button
type="button"
class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono cursor-pointer"
onClick={props.debugTools.toggle}
aria-label="Toggle debug tools"
aria-pressed={props.debugTools.visible}
>
DEV
</button>
)
}
return ( return (
<> <>
{["beta", "dev"].includes(channel) && ( {["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono"> <div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{channel.toUpperCase()} {import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
</div> </div>
)} )}
</> </>
+1 -15
View File
@@ -1,19 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { commandPaletteOptions, resolveKeybindOption, upsertCommandRegistration, type CommandOption } from "./command" import { resolveKeybindOption, upsertCommandRegistration } from "./command"
const paletteOptions: CommandOption[] = [
{ id: "settings.open", title: "Open settings" },
{ id: "session.undo", title: "Undo" },
{ id: "file.open", title: "Open file" },
{ id: "hidden", title: "Hidden", hidden: true },
{ id: "disabled", title: "Disabled", disabled: true },
]
describe("commandPaletteOptions", () => {
test("keeps visible enabled commands", () => {
expect(commandPaletteOptions(paletteOptions).map((option) => option.id)).toEqual(["settings.open", "session.undo"])
})
})
describe("upsertCommandRegistration", () => { describe("upsertCommandRegistration", () => {
test("replaces keyed registrations", () => { test("replaces keyed registrations", () => {
+2 -9
View File
@@ -11,7 +11,7 @@ import { Persist, persisted } from "@/utils/persist"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette" const PALETTE_ID = "command.palette"
export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p" const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested." const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"]) const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
@@ -87,13 +87,6 @@ export interface CommandOption {
onHighlight?: () => (() => void) | void onHighlight?: () => (() => void) | void
} }
export function commandPaletteOptions(options: CommandOption[]) {
return options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith(SUGGESTED_PREFIX) && option.id !== "file.open",
)
}
export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) { export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when) return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
} }
@@ -382,7 +375,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
} }
const showPalette = () => { const showPalette = () => {
run(PALETTE_ID, "palette") run("file.open", "palette")
} }
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -221,56 +221,4 @@ describe("createChildStoreManager", () => {
dispose() dispose()
} }
}) })
test("keeps non-bootstrapping children passive until a real directory access", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const offset = querySingles.length
const bootstraps: string[] = []
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap(directory) {
bootstraps.push(directory)
},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/project", { bootstrap: false })
const queries = querySingles.slice(offset)
expect(queries).toHaveLength(6)
expect(queries[0]?.().enabled).toBe(false)
expect(queries[3]?.().enabled).toBe(false)
expect(queries[4]?.().enabled).toBe(false)
expect(queries[5]?.().enabled).toBe(false)
expect(store.path.directory).toBe("/project")
expect(store.provider_ready).toBe(false)
expect(store.lsp_ready).toBe(false)
expect(bootstraps).toEqual([])
manager.child("/project")
expect(queries[0]?.().enabled).toBe(true)
expect(queries[3]?.().enabled).toBe(true)
expect(queries[4]?.().enabled).toBe(true)
expect(queries[5]?.().enabled).toBe(true)
expect(bootstraps).toEqual(["/project"])
manager.child("/project", { bootstrap: false })
expect(queries[0]?.().enabled).toBe(true)
} finally {
dispose()
}
})
}) })
@@ -45,8 +45,6 @@ export function createChildStoreManager(input: {
const disposers = new Map<string, () => void>() const disposers = new Map<string, () => void>()
const mcpDirectories = new Set<string>() const mcpDirectories = new Set<string>()
const mcpToggles = new Map<string, (enabled: boolean) => void>() const mcpToggles = new Map<string, (enabled: boolean) => void>()
const activeDirectories = new Set<string>()
const activationToggles = new Map<string, (enabled: boolean) => void>()
const markKey = (key: DirectoryKey) => { const markKey = (key: DirectoryKey) => {
if (!key) return if (!key) return
@@ -120,8 +118,6 @@ export function createChildStoreManager(input: {
lifecycle.delete(key) lifecycle.delete(key)
mcpDirectories.delete(key) mcpDirectories.delete(key)
mcpToggles.delete(key) mcpToggles.delete(key)
activeDirectories.delete(key)
activationToggles.delete(key)
const dispose = disposers.get(key) const dispose = disposers.get(key)
if (dispose) { if (dispose) {
dispose() dispose()
@@ -186,27 +182,20 @@ export function createChildStoreManager(input: {
const initialMeta = meta[0].value const initialMeta = meta[0].value
const initialIcon = icon[0].value const initialIcon = icon[0].value
const [mcpEnabled, setMcpEnabled] = createSignal(false) const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() })) const pathQuery = useQuery(() => input.queryOptions.path(key))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() })) const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() })) const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() })) const lspQuery = useQuery(() => input.queryOptions.lsp(key))
const providerQuery = useQuery(() => ({ const providerQuery = useQuery(() => input.queryOptions.providers(key))
...input.queryOptions.providers(key), const referenceQuery = useQuery(() => input.queryOptions.references(key))
enabled: instanceQueriesEnabled(),
}))
const referenceQuery = useQuery(() => ({
...input.queryOptions.references(key),
enabled: instanceQueriesEnabled(),
}))
const child = createStore<State>({ const child = createStore<State>({
project: "", project: "",
projectMeta: initialMeta, projectMeta: initialMeta,
icon: initialIcon, icon: initialIcon,
get provider_ready() { get provider_ready() {
return instanceQueriesEnabled() && !providerQuery.isLoading return !providerQuery.isLoading
}, },
get provider() { get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} } const EMPTY = { all: new Map(), connected: [], default: {} }
@@ -247,7 +236,7 @@ export function createChildStoreManager(input: {
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {}) return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
}, },
get lsp_ready() { get lsp_ready() {
return instanceQueriesEnabled() && !lspQuery.isLoading return !lspQuery.isLoading
}, },
get lsp() { get lsp() {
return lspQuery.isLoading ? [] : (lspQuery.data ?? []) return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
@@ -261,7 +250,6 @@ export function createChildStoreManager(input: {
children[key] = child children[key] = child
disposers.set(key, dispose) disposers.set(key, dispose)
mcpToggles.set(key, setMcpEnabled) mcpToggles.set(key, setMcpEnabled)
activationToggles.set(key, setInstanceQueriesEnabled)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => { const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return if (!(init instanceof Promise)) return
@@ -302,7 +290,6 @@ export function createChildStoreManager(input: {
pinForOwner(key) pinForOwner(key)
if (options.mcp) enableMcp(directory, key, childStore) if (options.mcp) enableMcp(directory, key, childStore)
const shouldBootstrap = options.bootstrap ?? true const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap) activate(key)
if (shouldBootstrap && childStore[0].status === "loading") { if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory) input.onBootstrap(directory)
} }
@@ -314,7 +301,6 @@ export function createChildStoreManager(input: {
const childStore = ensureChild(directory) const childStore = ensureChild(directory)
if (options.mcp) enableMcp(directory, key, childStore) if (options.mcp) enableMcp(directory, key, childStore)
const shouldBootstrap = options.bootstrap ?? true const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap) activate(key)
if (shouldBootstrap && childStore[0].status === "loading") { if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory) input.onBootstrap(directory)
} }
@@ -328,16 +314,6 @@ export function createChildStoreManager(input: {
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1]) if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
} }
// Passive Home/project metadata reads must not initialize the directory.
// A real directory access enables these queries once for the store lifetime.
// TODO(v2): After Home switches to v2.project.list and root-filtered,
// updated-time v2.session.list, remove any Home-only passive child creation.
function activate(key: DirectoryKey) {
if (activeDirectories.has(key)) return
activeDirectories.add(key)
activationToggles.get(key)?.(true)
}
function disableMcp(directory: string) { function disableMcp(directory: string) {
const key = directoryKey(directory) const key = directoryKey(directory)
if (!mcpDirectories.delete(key)) return if (!mcpDirectories.delete(key)) return
@@ -384,7 +360,6 @@ export function createChildStoreManager(input: {
unpin, unpin,
pinned, pinned,
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)), mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
disableMcp, disableMcp,
disposeDirectory, disposeDirectory,
runEviction, runEviction,
@@ -243,22 +243,6 @@ describe("applyDirectoryEvent", () => {
expect(store.session_status.ses_1).toBeUndefined() expect(store.session_status.ses_1).toBeUndefined()
}) })
test("ignores an archived session absent from a passive directory store", () => {
const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 }))
applyDirectoryEvent({
event: { type: "session.updated", properties: { info: rootSession({ id: "missing", archived: 10 }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session).toEqual([])
expect(store.sessionTotal).toBe(0)
})
test("cleans session caches when deleted and decrements only root totals", () => { test("cleans session caches when deleted and decrements only root totals", () => {
const cases = [ const cases = [
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1 }, { info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
@@ -146,14 +146,15 @@ export function applyDirectoryEvent(input: {
const info = (event.properties as { info: Session }).info const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id) const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (info.time.archived) { if (info.time.archived) {
if (!result.found) break
if (input.store.session[result.index]!.time.archived === info.time.archived) break if (input.store.session[result.index]!.time.archived === info.time.archived) break
input.setStore( if (result.found) {
"session", input.setStore(
produce((draft) => { "session",
draft.splice(result.index, 1) produce((draft) => {
}), draft.splice(result.index, 1)
) }),
)
}
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
@@ -1,154 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { SessionV2Info } from "@opencode-ai/sdk/v2/client"
import {
applyHomeSessionEvent,
appendHomeSessionEvent,
HOME_V2_SESSION_PAGE_LIMIT,
loadHomeSessionIndex,
homeSessionIndexSessions,
homeSessionIndexRefresh,
parseHomeSessionIndex,
retainHomeSessions,
} from "./home-session-index"
const session = (input: {
id: string
directory?: string
parentID?: string
archived?: number
updated?: number
}) => ({
id: input.id,
parentID: input.parentID,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: input.updated ?? 1, archived: input.archived },
title: input.id,
location: { directory: input.directory ?? "/project" },
})
describe("Home V2 session index", () => {
test("loads the Home index with one global V2 request", async () => {
const calls: unknown[] = []
const result = await loadHomeSessionIndex(async (input) => {
calls.push(input)
return { data: { data: [session({ id: "root" })], cursor: {} } }
})
expect(result.sessions).toHaveLength(1)
expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }])
})
test("loads subsequent pages until the session index is complete", async () => {
const calls: unknown[] = []
const controller = new AbortController()
const result = await loadHomeSessionIndex(
async (input, options) => {
calls.push({ input, signal: options.signal })
if (!("cursor" in input)) {
return {
data: {
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
session({ id: `page-1-${index}` }),
),
cursor: { next: "next-page" },
},
}
}
return { data: { data: [session({ id: "page-2" })], cursor: {} } }
},
0,
controller.signal,
)
expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
expect(calls).toEqual([
{ input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal },
{
input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" },
signal: controller.signal,
},
])
})
test("maps visible roots to Home session summaries", () => {
const activeNull = {
...session({ id: "active-null", updated: 20 }),
time: { created: 1, updated: 20, archived: null },
} as unknown as SessionV2Info
const result = parseHomeSessionIndex([
session({ id: "root", updated: 30 }),
activeNull,
session({ id: "child", parentID: "root", updated: 40 }),
session({ id: "archived", archived: 50, updated: 50 }),
])
expect(result).toEqual([
expect.objectContaining({
id: "root",
slug: "root",
version: "",
directory: "/project",
projectID: "project",
title: "root",
time: { created: 1, updated: 30 },
}),
expect.objectContaining({
id: "active-null",
time: { created: 1, updated: 20, archived: null },
}),
])
})
test("preserves the per-directory Home retention limit", () => {
const now = 10 * 60 * 60 * 1000
const sessions = Array.from({ length: 80 }, (_, index) => ({
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
directory: index % 2 === 0 ? "/one" : "/two",
}))
const retained = retainHomeSessions(sessions, 10, now)
expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
})
test("replays session events over the loaded index", () => {
const initial = parseHomeSessionIndex([session({ id: "old" })])
const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
const afterCreate = applyHomeSessionEvent(initial, {
type: "session.created",
properties: { sessionID: created.id, info: created },
})
expect(
applyHomeSessionEvent(afterCreate, {
type: "session.deleted",
properties: { sessionID: initial[0]!.id, info: initial[0]! },
}),
).toEqual([created])
})
test("applies only events newer than the index baseline", () => {
const initial = parseHomeSessionIndex([session({ id: "old" })])
const stale = { ...initial[0], title: "stale" }
const current = { ...initial[0], title: "current" }
const first = appendHomeSessionEvent(undefined, {
type: "session.updated",
properties: { sessionID: stale.id, info: stale },
})
const events = appendHomeSessionEvent(first, {
type: "session.updated",
properties: { sessionID: current.id, info: current },
})
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
})
test("refetches after reconnect, disposal, and session moves", () => {
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
})
})
@@ -1,174 +0,0 @@
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
import type { QueryClient } from "@tanstack/solid-query"
import { trimSessions } from "./session-trim"
import { pathKey } from "@/utils/path-key"
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
export type HomeSessionEvent = {
type: "session.created" | "session.updated" | "session.deleted"
properties: { sessionID: string; info: Session }
}
export type HomeSessionEvents = {
sequence: number
entries: Array<{ sequence: number; event: HomeSessionEvent }>
}
export type HomeSessionIndex = {
sessions: Session[]
eventSequence: number
}
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
type HomeSessionPage = { data?: V2SessionListResponse }
export async function loadHomeSessionIndex(
list: (
input: { limit: number; order: "desc"; cursor?: string },
options: { signal?: AbortSignal },
) => Promise<HomeSessionPage>,
eventSequence = 0,
signal?: AbortSignal,
) {
const data: SessionV2Info[] = []
let cursor: string | undefined
for (;;) {
const response = await list(
{
limit: HOME_V2_SESSION_PAGE_LIMIT,
order: "desc",
...(cursor ? { cursor } : {}),
},
{ signal },
)
const page = response.data!
data.push(...page.data)
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
return { sessions: parseHomeSessionIndex(data), eventSequence }
cursor = page.cursor.next
}
}
export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) {
const sequence = (current?.sequence ?? 0) + 1
return {
sequence,
entries: [...(current?.entries ?? []), { sequence, event }],
}
}
export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents {
return {
sequence: current?.sequence ?? sequence,
entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence),
}
}
export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
if (!index) return []
return (events?.entries ?? [])
.filter((entry) => entry.sequence > index.eventSequence)
.reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions)
}
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
if (event === "server.connected") return { connected: true, refetch: connected }
return {
connected,
refetch: event === "global.disposed" || event === "session.next.moved",
}
}
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
const indexKey = homeSessionIndexKey(server)
const eventsKey = homeSessionEventsKey(server)
let connected = false
return {
indexKey,
eventsKey,
eventSequence() {
return queryClient.getQueryData<HomeSessionEvents>(eventsKey)?.sequence ?? 0
},
complete(sequence: number) {
// Keep events received after the fetch began so its response cannot overwrite them.
queryClient.setQueryData<HomeSessionEvents>(eventsKey, (current) => trimHomeSessionEvents(current, sequence))
},
sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
return homeSessionIndexSessions(index, events)
},
apply(event: HomeSessionEvent) {
if (!queryClient.getQueryState(indexKey)) return
const next = appendHomeSessionEvent(queryClient.getQueryData<HomeSessionEvents>(eventsKey), event)
if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) {
queryClient.setQueryData(eventsKey, next)
return
}
const index = queryClient.getQueryData<HomeSessionIndex>(indexKey)
if (index) {
queryClient.setQueryData<HomeSessionIndex>(indexKey, {
sessions: homeSessionIndexSessions(index, next),
eventSequence: next.sequence,
})
}
queryClient.setQueryData<HomeSessionEvents>(eventsKey, { sequence: next.sequence, entries: [] })
},
refresh(event: Event["type"]) {
const result = homeSessionIndexRefresh(event, connected)
connected = result.connected
if (!result.refetch) return
void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" })
},
}
}
// TODO(v2): This deliberately dumb full-table scan is necessary because the
// current V2 API orders by creation time and cannot filter roots, archives, or
// multiple directories. A bounded page could omit an old session updated today.
// Once released, use client.v2.project.list() and client.v2.session.list({
// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
return sessions.flatMap((item) => {
if (item.parentID || typeof item.time.archived === "number") return []
return [toLegacySummary(item)]
})
}
export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
}
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
const info = event.properties.info
const index = sessions.findIndex((session) => session.id === info.id)
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
if (index === -1) return sessions
return sessions.toSpliced(index, 1)
}
if (event.type !== "session.created" && event.type !== "session.updated") return sessions
if (index === -1) return [...sessions, info]
return sessions.with(index, info)
}
function toLegacySummary(session: SessionV2Info): Session {
return {
id: session.id,
slug: session.id,
projectID: session.projectID,
workspaceID: session.location.workspaceID,
directory: session.location.directory,
path: session.subpath,
parentID: session.parentID,
cost: session.cost,
tokens: session.tokens,
title: session.title,
agent: session.agent,
model: session.model,
version: "",
time: session.time,
}
}
@@ -1,29 +0,0 @@
import { describe, expect, test } from "bun:test"
import { hasCustomAgent, resolveAgent } from "./local-agent"
describe("hasCustomAgent", () => {
test("detects explicitly custom agents", () => {
expect(hasCustomAgent([{ native: true }, { native: false }])).toBe(true)
})
test("ignores built-in and unclassified agents", () => {
expect(hasCustomAgent([{ native: true }, {}])).toBe(false)
})
})
describe("resolveAgent", () => {
const agents = [{ name: "plan" }, { name: "build" }, { name: "custom" }]
test("uses the requested available agent", () => {
expect(resolveAgent(agents, "custom")?.name).toBe("custom")
})
test("defaults to build", () => {
expect(resolveAgent(agents)?.name).toBe("build")
expect(resolveAgent(agents, "missing")?.name).toBe("build")
})
test("uses the first agent when build is unavailable", () => {
expect(resolveAgent([{ name: "custom" }], "missing")?.name).toBe("custom")
})
})
-7
View File
@@ -1,7 +0,0 @@
export function hasCustomAgent(items: Array<{ native?: boolean }>) {
return items.some((item) => item.native === false)
}
export function resolveAgent<T extends { name: string }>(items: T[], name?: string) {
return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0]
}
+4 -7
View File
@@ -4,10 +4,8 @@ import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js" import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { useSettings } from "@/context/settings"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { hasCustomAgent, resolveAgent } from "./local-agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
@@ -64,11 +62,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory) const providers = useProviders(() => sdk().directory)
const models = useModels() const models = useModels()
const settings = useSettings()
const id = createMemo(() => params.id || undefined) const id = createMemo(() => params.id || undefined)
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved, , savedReady] = persisted( const [saved, setSaved, , savedReady] = persisted(
@@ -111,7 +107,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
const pickAgent = (name: string | undefined) => { const pickAgent = (name: string | undefined) => {
return resolveAgent(list(), name) const items = list()
if (items.length === 0) return
return items.find((item) => item.name === name) ?? items[0]
} }
createEffect(() => { createEffect(() => {
@@ -182,9 +180,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = { const agent = {
list, list,
visible: agentsVisible,
current() { current() {
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build") return pickAgent(scope()?.agent ?? store.current)
}, },
set(name: string | undefined) { set(name: string | undefined) {
const item = pickAgent(name) const item = pickAgent(name)
+1 -8
View File
@@ -176,14 +176,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
onCleanup(() => states.forEach((value) => value.dispose())) onCleanup(() => states.forEach((value) => value.dispose()))
const selected = () => { const selected = () => ensure(activeServer())
const list = global.servers.list()
const key = activeServer()
if (list.some((conn) => ServerConnection.key(conn) === key)) return ensure(key)
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
if (!conn) throw new Error("Notification server not found")
return ensure(ServerConnection.key(conn))
}
return { return {
ready: () => selected().ready(), ready: () => selected().ready(),
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/core/util/encode" 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 }) => const session = (input: { id: string; parentID?: string }) =>
({ ({
@@ -69,7 +69,6 @@ describe("autoRespondsPermission", () => {
} }
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true) 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", () => { test("session-level override takes precedence over directory-level", () => {
@@ -82,28 +81,6 @@ describe("autoRespondsPermission", () => {
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false) 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", () => { describe("isDirectoryAutoAccepting", () => {
@@ -11,7 +11,8 @@ export function directoryAcceptKey(directory: string) {
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) { function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory) 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) { export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
@@ -43,18 +44,8 @@ export function autoRespondsPermission(
permission: { sessionID: string }, permission: { sessionID: string },
directory?: string, directory?: string,
) { ) {
const value = sessionAutoAccept(autoAccept, session, permission, directory) const value = sessionLineage(session, permission.sessionID)
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)
.map((id) => accepted(autoAccept, id, directory)) .map((id) => accepted(autoAccept, id, directory))
.find((item): item is boolean => item !== undefined) .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 { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { ServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import type { ServerSync } from "./server-sync" import { useServerSync } from "./server-sync"
import { useParams, useSearchParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { decode64 } from "@/utils/base64" 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 { import {
acceptKey, acceptKey,
directoryAcceptKey, directoryAcceptKey,
isDirectoryAutoAccepting, isDirectoryAutoAccepting,
autoRespondsPermission, autoRespondsPermission,
sessionAutoAccept,
} from "./permission-auto-respond" } from "./permission-auto-respond"
type PermissionRespondFn = (input: { type PermissionRespondFn = (input: {
@@ -54,417 +47,234 @@ function hasPermissionPromptRules(permission: unknown) {
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission", name: "Permission",
gate: false, gate: false,
init: () => { init: (props: { directory?: Accessor<string | undefined> }) => {
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>() const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>() const serverSDK = useServerSDK()
const global = useGlobal() const serverSync = useServerSync()
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)
})
const permissionsEnabled = createMemo(() => { const permissionsEnabled = createMemo(() => {
const directory = activeDirectory() const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return false 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 { return {
ready: () => selected().ready(), ready,
ensureServerState: (key: ServerConnection.Key) => ensure(key).api, respond,
currentServerState: () => selected().api,
respond(input: Parameters<PermissionRespondFn>[0]) {
selected().respond(input)
},
autoResponds(permission: PermissionRequest, directory?: string) { autoResponds(permission: PermissionRequest, directory?: string) {
return selected().autoResponds(permission, directory) return shouldAutoRespond(permission, directory)
},
isAutoAccepting(sessionID: string, directory?: string) {
return selected().isAutoAccepting(sessionID, directory)
},
isAutoAcceptingDirectory(directory: string) {
return selected().isAutoAcceptingDirectory(directory)
}, },
isAutoAccepting,
isAutoAcceptingDirectory,
toggleAutoAccept(sessionID: string, directory: string) { toggleAutoAccept(sessionID: string, directory: string) {
selected().toggleAutoAccept(sessionID, directory) if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory)
return
}
enable(sessionID, directory)
}, },
toggleAutoAcceptDirectory(directory: string) { toggleAutoAcceptDirectory(directory: string) {
selected().toggleAutoAcceptDirectory(directory) if (isAutoAcceptingDirectory(directory)) {
disableDirectory(directory)
return
}
enableDirectory(directory)
}, },
enableAutoAccept(sessionID: string, directory: string) { enableAutoAccept(sessionID: string, directory: string) {
selected().enableAutoAccept(sessionID, directory) if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory)
}, },
disableAutoAccept(sessionID: string, directory?: string) { disableAutoAccept(sessionID: string, directory?: string) {
selected().disableAutoAccept(sessionID, directory) disable(sessionID, directory)
}, },
permissionsEnabled, permissionsEnabled,
isPermissionAllowAll(directory: string) { 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
@@ -115,9 +115,6 @@ type PlatformBase = {
/** Export collected diagnostic logs (desktop only) */ /** Export collected diagnostic logs (desktop only) */
exportDebugLogs?(): Promise<string> exportDebugLogs?(): Promise<string>
/** Force focus styles on interactive elements through desktop devtools (desktop only) */
setForceFocus?(enabled: boolean): Promise<void>
/** Record a fatal renderer error in platform logs (desktop only) */ /** Record a fatal renderer error in platform logs (desktop only) */
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void> recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
} }
+1 -2
View File
@@ -64,7 +64,7 @@ export type PromptScope = { draftID: string } | { dir: string; id?: string }
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export type PromptStore = { type PromptStore = {
prompt: Prompt prompt: Prompt
cursor?: number cursor?: number
model?: PromptModel model?: PromptModel
@@ -189,7 +189,6 @@ function promptStore(initial?: InitialPrompt): PromptStore {
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) { function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
const actions = createPromptActions(setStore) const actions = createPromptActions(setStore)
const value = { const value = {
store: [() => store, setStore] as [Accessor<PromptStore>, SetStoreFunction<PromptStore>],
current: () => store.prompt, current: () => store.prompt,
cursor: createMemo(() => store.cursor), cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
+6 -18
View File
@@ -1,7 +1,7 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams, useSearchParams } from "@solidjs/router" import { useParams, useSearchParams } from "@solidjs/router"
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js" import { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { requireServerKey } from "@/utils/session-route" import { requireServerKey } from "@/utils/session-route"
import { ServerConnection } from "./server" import { ServerConnection } from "./server"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
@@ -36,7 +36,6 @@ export type {
ImageAttachmentPart, ImageAttachmentPart,
Prompt, Prompt,
PromptModel, PromptModel,
PromptStore,
PromptScope, PromptScope,
PromptSession, PromptSession,
TextPart, TextPart,
@@ -133,29 +132,18 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const pick = (scope?: PromptScope) => (scope ? load(scope) : session()) const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
const ready = createPromptReady(session) const ready = createPromptReady(session)
const withSuspense = <T,>(cb: () => T): (() => T) =>
createResource(
async () => {
const value = cb()
await session().ready.promise
return value
},
cb,
{ initialValue: cb() },
)[0]
return { return {
ready, ready,
capture: (scope?: PromptScope) => pick(scope).capture(), capture: (scope?: PromptScope) => pick(scope).capture(),
current: withSuspense(() => session().current()), current: () => session().current(),
cursor: withSuspense(() => session().cursor()), cursor: () => session().cursor(),
dirty: withSuspense(() => session().dirty()), dirty: () => session().dirty(),
model: { model: {
current: withSuspense(() => session().model.current()), current: () => session().model.current(),
set: (model: PromptModel | undefined) => session().model.set(model), set: (model: PromptModel | undefined) => session().model.set(model),
}, },
context: { context: {
items: withSuspense(() => session().context.items()), items: () => session().context.items(),
add: (item: ContextItem) => session().context.add(item), add: (item: ContextItem) => session().context.add(item),
remove: (key: string) => session().context.remove(key), remove: (key: string) => session().context.remove(key),
removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID), removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),
@@ -174,7 +174,7 @@ describe("server session", () => {
await ctx.store.sync("root") await ctx.store.sync("root")
expect(ctx.get).toEqual([{ sessionID: "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([]) expect(ctx.store.data.message.root).toEqual([])
}) })
@@ -194,55 +194,12 @@ describe("server session", () => {
await store.sync("child") 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(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants]) expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(true) expect(store.history.more("child")).toBe(true)
}) })
test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
const missing = Promise.withResolvers<SingleMessageResponse>()
const assistant = assistantMessage("message-2", "message-missing")
const client = rootMessageClient([response([{ info: assistant, parts: [] }], "older")], [missing.promise])
const store = createServerSession(client)
const loading = store.sync("child")
await client.rootRequested(1)
missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } }))
await loading
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
expect(store.data.message.child).toEqual([assistant])
expect(store.history.more("child")).toBe(true)
})
test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
const missing = Promise.withResolvers<SingleMessageResponse>()
const parent = userMessage("message-1")
const part = textPart(parent.id)
const assistant = assistantMessage("message-2", parent.id)
const client = rootMessageClient(
[
response([
{ info: parent, parts: [part] },
{ info: assistant, parts: [] },
]),
response([{ info: assistant, parts: [] }], "older"),
],
[missing.promise],
)
const store = createServerSession(client)
await store.sync("child")
const loading = store.sync("child", { force: true })
await client.rootRequested(1)
missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } }))
await loading
expect(store.data.message.child).toEqual([assistant])
expect(store.data.part[parent.id]).toBeUndefined()
})
test("does not let an optimistic user suppress initial root backfill", async () => { test("does not let an optimistic user suppress initial root backfill", async () => {
const user = userMessage("message-1") const user = userMessage("message-1")
const part = textPart(user.id) const part = textPart(user.id)
+2 -13
View File
@@ -21,7 +21,7 @@ import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } fro
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) 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 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 SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 20 const initialMessagePageSize = 2
const historyMessagePageSize = 200 const historyMessagePageSize = 200
const sessionInfoLimit = 2_048 const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set() const emptyIDs: ReadonlySet<string> = new Set()
@@ -110,7 +110,6 @@ function reconcileFetched<T extends { id: string }>(
options: { options: {
touched?: ReadonlySet<string> touched?: ReadonlySet<string>
retained?: ReadonlySet<string> retained?: ReadonlySet<string>
removed?: ReadonlySet<string>
preserveUnfetched?: boolean | ((item: T) => boolean) preserveUnfetched?: boolean | ((item: T) => boolean)
} = {}, } = {},
) { ) {
@@ -133,7 +132,6 @@ function reconcileFetched<T extends { id: string }>(
if (item) result.set(id, item) if (item) result.set(id, item)
if (!item) result.delete(id) if (!item) result.delete(id)
} }
for (const id of options.removed ?? emptyIDs) result.delete(id)
return [...result.values()].sort((a, b) => cmp(a.id, b.id)) return [...result.values()].sort((a, b) => cmp(a.id, b.id))
} }
@@ -579,7 +577,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], { const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
touched: touchedMessages, touched: touchedMessages,
retained: load?.retainedMessages, retained: load?.retainedMessages,
removed: load?.removedMessages,
preserveUnfetched, preserveUnfetched,
}) })
batch(() => { batch(() => {
@@ -648,15 +645,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
if (generations.get(sessionID) !== active) break if (generations.get(sessionID) !== active) break
const parent = await fetchMessage(sessionID, parentID, () => const parent = await fetchMessage(sessionID, parentID, () =>
resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)), resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)),
).catch((error) => { )
const cause = error instanceof Error && typeof error.cause === "object" ? error.cause : undefined
if (cause && "status" in cause && cause.status === 404) {
load.removedMessages.add(parentID)
return
}
throw error
})
if (!parent) continue
if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`) if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`)
parents.push(parent) parents.push(parent)
} }
+1 -13
View File
@@ -43,7 +43,6 @@ import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server" import { ServerConnection, useServer } from "./server"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
import { toggleMcp } from "./global-sync/mcp" import { toggleMcp } from "./global-sync/mcp"
import { createServerSession } from "./server-session" import { createServerSession } from "./server-session"
@@ -154,7 +153,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}) })
const queryClient = useQueryClient() const queryClient = useQueryClient()
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
let bootedAt = 0 let bootedAt = 0
let bootingRoot = false let bootingRoot = false
@@ -378,10 +376,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const recent = bootingRoot || Date.now() - bootedAt < 1500 const recent = bootingRoot || Date.now() - bootedAt < 1500
session.apply(event) session.apply(event)
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
homeSessions.apply(event)
}
homeSessions.refresh(event.type)
if (directory === "global") { if (directory === "global") {
applyGlobalEvent({ applyGlobalEvent({
@@ -396,7 +390,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (event.type === "server.connected" || event.type === "global.disposed") { if (event.type === "server.connected" || event.type === "global.disposed") {
if (recent) return if (recent) return
for (const directory of Object.keys(children.children)) { for (const directory of Object.keys(children.children)) {
if (!children.active(directory)) continue
queue.push(directory) queue.push(directory)
} }
} }
@@ -412,19 +405,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
directory, directory,
store, store,
setStore, setStore,
push: (directory) => { push: queue.push,
if (children.active(directory)) queue.push(directory)
},
retainedLimit: sessionMeta.get(key)?.limit, retainedLimit: sessionMeta.get(key)?.limit,
sessionContent: false, sessionContent: false,
permission: session.data.permission, permission: session.data.permission,
vcsCache: children.vcsCache.get(key), vcsCache: children.vcsCache.get(key),
loadLsp: () => { loadLsp: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key)) void queryClient.fetchQuery(queryOptionsApi.lsp(key))
}, },
loadReferences: () => { loadReferences: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.references(key)) void queryClient.fetchQuery(queryOptionsApi.references(key))
}, },
}) })
@@ -497,7 +486,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
updateConfig: updateConfigMutation.mutateAsync, updateConfig: updateConfigMutation.mutateAsync,
project: projectApi, project: projectApi,
session, session,
homeSessions,
mcp: { mcp: {
toggle: async (directory: string, name: string) => { toggle: async (directory: string, name: string) => {
const key = directoryKey(directory) const key = directoryKey(directory)
-32
View File
@@ -1,13 +1,10 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { import {
isAppUpgrade,
layoutTransitionState, layoutTransitionState,
maximumSunsetTimeout, maximumSunsetTimeout,
newLayoutDesignsDefault, newLayoutDesignsDefault,
nextSunsetCheckDelay, nextSunsetCheckDelay,
resolveNewLayoutDesigns, resolveNewLayoutDesigns,
shouldDisplayTabsToast,
shouldEnableNewLayout,
} from "./settings" } from "./settings"
describe("layout transition", () => { describe("layout transition", () => {
@@ -40,33 +37,4 @@ describe("layout transition", () => {
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000) expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0) 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)
})
}) })
+6 -104
View File
@@ -2,7 +2,6 @@ import { createStore, reconcile } from "solid-js/store"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
import { usePlatform } from "@/context/platform"
export interface NotificationSettings { export interface NotificationSettings {
agent: boolean agent: boolean
@@ -37,7 +36,6 @@ export interface Settings {
newLayoutDesigns?: boolean newLayoutDesigns?: boolean
layoutTransitionEligible?: boolean layoutTransitionEligible?: boolean
newInterfaceNoticeDismissed?: boolean newInterfaceNoticeDismissed?: boolean
shouldDisplayTabsToast?: boolean
} }
appearance: { appearance: {
fontSize: number fontSize: number
@@ -59,49 +57,7 @@ export const terminalDefault = "JetBrainsMono Nerd Font Mono"
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export const newLayoutDesignsDefault = true export const newLayoutDesignsDefault = true
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show. // 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) export const oldInterfaceSunset = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" ? new Date(2026, 7, 14) : null
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) { export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
return { return {
@@ -219,17 +175,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
name: "Settings", name: "Settings",
gate: false, gate: false,
init: () => { init: () => {
const platform = usePlatform()
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings)) 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 showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch) const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus) const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
@@ -239,26 +185,18 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
) )
const sunset = oldInterfaceSunset const sunset = oldInterfaceSunset
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false) const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean") const layoutTransitionClassified = createMemo(
() => typeof store.general?.layoutTransitionEligible === "boolean",
)
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false) const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false) const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
const layoutUpgrade = createMemo(() =>
launchState.classified && !launchState.migrationApplied
? shouldEnableNewLayout(launchState.previous, platform.version)
: false,
)
const layoutTransition = createMemo(() => const layoutTransition = createMemo(() =>
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()), layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
) )
const newLayoutDesigns = createMemo(() => { const newLayoutDesigns = createMemo(() => {
if (layoutUpgrade()) return true
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
if (!layoutTransitionClassified()) { if (!layoutTransitionClassified()) {
return resolveNewLayoutDesigns( return resolveNewLayoutDesigns(oldInterfaceRetired(), store.general?.newLayoutDesigns, legacyNewLayoutDesignsDefault)
oldInterfaceRetired(),
store.general?.newLayoutDesigns,
legacyNewLayoutDesignsDefault,
)
} }
return resolveNewLayoutDesigns( return resolveNewLayoutDesigns(
oldInterfaceRetired(), oldInterfaceRetired(),
@@ -283,35 +221,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
}) })
} }
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(() => { createEffect(() => {
if (!ready() || !oldInterfaceRetired()) return if (!ready() || !oldInterfaceRetired()) return
if (store.general?.newLayoutDesigns === true) return if (store.general?.newLayoutDesigns === true) return
@@ -405,10 +314,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
}, },
newLayoutDesigns, newLayoutDesigns,
setNewLayoutDesigns(value: boolean) { setNewLayoutDesigns(value: boolean) {
const next = oldInterfaceRetired() ? true : value setStore("general", "newLayoutDesigns", oldInterfaceRetired() ? true : value)
if (newLayoutDesigns() === next) return
setStore("general", "newLayoutDesigns", next)
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
}, },
layoutTransitionClassified, layoutTransitionClassified,
setOldLayoutEligible(eligible: boolean) { setOldLayoutEligible(eligible: boolean) {
@@ -421,10 +327,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
dismissNewInterfaceNotice() { dismissNewInterfaceNotice() {
setStore("general", "newInterfaceNoticeDismissed", true) setStore("general", "newInterfaceNoticeDismissed", true)
}, },
shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
dismissTabsToast() {
setStore("general", "shouldDisplayTabsToast", false)
},
}, },
visibility: { visibility: {
fileTree: visible(showFileTree), fileTree: visible(showFileTree),
+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}`) if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
return tab 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 draftID = uuid()
const tab = { type: "draft" as const, draftID, ...draft } const tab = { type: "draft" as const, draftID, ...draft }
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model })) memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
await startTransition(() => { void startTransition(() => {
setStore( setStore(
produce((tabs) => { produce((tabs) => {
tabs.push(tab) tabs.push(tab)
@@ -220,7 +220,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
) )
navigate(draftHref(draftID)) navigate(draftHref(draftID))
}) })
return tab
}, },
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) { updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
void startTransition(() => { void startTransition(() => {
+5 -68
View File
@@ -163,43 +163,6 @@ function createWorkspaceTerminalSession(
all: [], 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 pickNextTerminalNumber = () => {
const existingTitleNumbers = new Set( const existingTitleNumbers = new Set(
@@ -304,33 +267,23 @@ function createWorkspaceTerminalSession(
setStore("all", []) setStore("all", [])
}) })
}, },
new(options?: { focus?: boolean }) { new() {
const nextNumber = pickNextTerminalNumber() const nextNumber = pickNextTerminalNumber()
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
sdk.client.pty sdk.client.pty
.create({ title: defaultTitle(nextNumber) }) .create({ title: defaultTitle(nextNumber) })
.then((pty: { data?: { id?: string; title?: string } }) => { .then((pty: { data?: { id?: string; title?: string } }) => {
const id = pty.data?.id const id = pty.data?.id
if (!id) { if (!id) return
if (focusRequest !== undefined) cancelFocus(focusRequest)
return
}
const newTerminal = { const newTerminal = {
id, id,
title: pty.data?.title ?? defaultTitle(nextNumber), title: pty.data?.title ?? defaultTitle(nextNumber),
titleNumber: nextNumber, titleNumber: nextNumber,
} }
batch(() => { setStore("all", store.all.length, newTerminal)
setStore("all", store.all.length, newTerminal) setStore("active", id)
setStore("active", id)
if (focusRequest !== undefined && ui.focus?.request === focusRequest) {
setUi("focus", { request: focusRequest, id, pending: false })
}
})
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (focusRequest !== undefined) cancelFocus(focusRequest)
console.error("Failed to create terminal", error) console.error("Failed to create terminal", error)
}) })
}, },
@@ -371,18 +324,6 @@ function createWorkspaceTerminalSession(
open(id: string) { open(id: string) {
setStore("active", id) setStore("active", id)
}, },
requestFocus(id?: string) {
requestFocus(id)
},
focusRequested(id?: string) {
return focusRequested(id)
},
consumeFocus(id: string) {
consumeFocus(id)
},
cancelFocus() {
cancelFocus()
},
next() { next() {
const index = store.all.findIndex((x) => x.id === store.active) const index = store.all.findIndex((x) => x.id === store.active)
if (index === -1) return if (index === -1) return
@@ -501,17 +442,13 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
ready: () => workspace().ready(), ready: () => workspace().ready(),
all: () => workspace().all(), all: () => workspace().all(),
active: () => workspace().active(), active: () => workspace().active(),
new: (options?: { focus?: boolean }) => workspace().new(options), new: () => workspace().new(),
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty), update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
trim: (id: string) => workspace().trim(id), trim: (id: string) => workspace().trim(id),
trimAll: () => workspace().trimAll(), trimAll: () => workspace().trimAll(),
clone: (id: string) => workspace().clone(id), clone: (id: string) => workspace().clone(id),
bind: () => workspace(), bind: () => workspace(),
open: (id: string) => workspace().open(id), 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), close: (id: string) => workspace().close(id),
move: (id: string, to: number) => workspace().move(id, to), move: (id: string, to: number) => workspace().move(id, to),
next: () => workspace().next(), next: () => workspace().next(),
-2
View File
@@ -86,7 +86,6 @@ export const dict = {
"command.session.unshare": "إلغاء مشاركة الجلسة", "command.session.unshare": "إلغاء مشاركة الجلسة",
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة", "command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات", "palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
"palette.search.placeholder.home": "البحث في الأوامر والجلسات",
"palette.empty": "لا توجد نتائج", "palette.empty": "لا توجد نتائج",
"palette.group.commands": "الأوامر", "palette.group.commands": "الأوامر",
"palette.group.files": "الملفات", "palette.group.files": "الملفات",
@@ -613,7 +612,6 @@ export const dict = {
"session.new.workspace.triggerLocal": "محلي", "session.new.workspace.triggerLocal": "محلي",
"session.new.workspace.local": "المستودع المحلي", "session.new.workspace.local": "المستودع المحلي",
"session.new.workspace.existing": "مساحة عمل…", "session.new.workspace.existing": "مساحة عمل…",
"session.new.git.none": "لا يوجد Git",
"session.new.lastModified": "آخر تعديل", "session.new.lastModified": "آخر تعديل",
"session.header.search.placeholder": "بحث {{project}}", "session.header.search.placeholder": "بحث {{project}}",
"session.header.searchFiles": "بحث عن الملفات", "session.header.searchFiles": "بحث عن الملفات",
-2
View File
@@ -86,7 +86,6 @@ export const dict = {
"command.session.unshare": "Parar de compartilhar sessão", "command.session.unshare": "Parar de compartilhar sessão",
"command.session.unshare.description": "Parar de compartilhar esta sessão", "command.session.unshare.description": "Parar de compartilhar esta sessão",
"palette.search.placeholder": "Buscar arquivos, comandos e sessões", "palette.search.placeholder": "Buscar arquivos, comandos e sessões",
"palette.search.placeholder.home": "Buscar comandos e sessões",
"palette.empty": "Nenhum resultado encontrado", "palette.empty": "Nenhum resultado encontrado",
"palette.group.commands": "Comandos", "palette.group.commands": "Comandos",
"palette.group.files": "Arquivos", "palette.group.files": "Arquivos",
@@ -619,7 +618,6 @@ export const dict = {
"session.new.workspace.triggerLocal": "Local", "session.new.workspace.triggerLocal": "Local",
"session.new.workspace.local": "Repositório local", "session.new.workspace.local": "Repositório local",
"session.new.workspace.existing": "Espaço de trabalho…", "session.new.workspace.existing": "Espaço de trabalho…",
"session.new.git.none": "Sem Git",
"session.new.lastModified": "Última modificação", "session.new.lastModified": "Última modificação",
"session.header.search.placeholder": "Buscar {{project}}", "session.header.search.placeholder": "Buscar {{project}}",
"session.header.searchFiles": "Buscar arquivos", "session.header.searchFiles": "Buscar arquivos",
-2
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "Zaustavi dijeljenje ove sesije", "command.session.unshare.description": "Zaustavi dijeljenje ove sesije",
"palette.search.placeholder": "Pretraži datoteke, komande i sesije", "palette.search.placeholder": "Pretraži datoteke, komande i sesije",
"palette.search.placeholder.home": "Pretraži komande i sesije",
"palette.empty": "Nema rezultata", "palette.empty": "Nema rezultata",
"palette.group.commands": "Komande", "palette.group.commands": "Komande",
"palette.group.files": "Datoteke", "palette.group.files": "Datoteke",
@@ -675,7 +674,6 @@ export const dict = {
"session.new.workspace.triggerLocal": "Lokalno", "session.new.workspace.triggerLocal": "Lokalno",
"session.new.workspace.local": "Lokalni repozitorij", "session.new.workspace.local": "Lokalni repozitorij",
"session.new.workspace.existing": "Radni prostor…", "session.new.workspace.existing": "Radni prostor…",
"session.new.git.none": "Nema Gita",
"session.new.lastModified": "Posljednja izmjena", "session.new.lastModified": "Posljednja izmjena",
"session.header.search.placeholder": "Pretraži {{project}}", "session.header.search.placeholder": "Pretraži {{project}}",
-2
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "Stop med at dele denne session", "command.session.unshare.description": "Stop med at dele denne session",
"palette.search.placeholder": "Søg i filer, kommandoer og sessioner", "palette.search.placeholder": "Søg i filer, kommandoer og sessioner",
"palette.search.placeholder.home": "Søg i kommandoer og sessioner",
"palette.empty": "Ingen resultater fundet", "palette.empty": "Ingen resultater fundet",
"palette.group.commands": "Kommandoer", "palette.group.commands": "Kommandoer",
"palette.group.files": "Filer", "palette.group.files": "Filer",
@@ -670,7 +669,6 @@ export const dict = {
"session.new.workspace.triggerLocal": "Lokal", "session.new.workspace.triggerLocal": "Lokal",
"session.new.workspace.local": "Lokalt repository", "session.new.workspace.local": "Lokalt repository",
"session.new.workspace.existing": "Arbejdsområde…", "session.new.workspace.existing": "Arbejdsområde…",
"session.new.git.none": "Ingen Git",
"session.new.lastModified": "Sidst ændret", "session.new.lastModified": "Sidst ændret",
"session.header.search.placeholder": "Søg {{project}}", "session.header.search.placeholder": "Søg {{project}}",
-2
View File
@@ -90,7 +90,6 @@ export const dict = {
"command.session.unshare": "Teilen der Sitzung aufheben", "command.session.unshare": "Teilen der Sitzung aufheben",
"command.session.unshare.description": "Teilen dieser Sitzung beenden", "command.session.unshare.description": "Teilen dieser Sitzung beenden",
"palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen", "palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen",
"palette.search.placeholder.home": "Befehle und Sitzungen durchsuchen",
"palette.empty": "Keine Ergebnisse gefunden", "palette.empty": "Keine Ergebnisse gefunden",
"palette.group.commands": "Befehle", "palette.group.commands": "Befehle",
"palette.group.files": "Dateien", "palette.group.files": "Dateien",
@@ -628,7 +627,6 @@ export const dict = {
"session.new.workspace.triggerLocal": "Lokal", "session.new.workspace.triggerLocal": "Lokal",
"session.new.workspace.local": "Lokales Repository", "session.new.workspace.local": "Lokales Repository",
"session.new.workspace.existing": "Arbeitsbereich…", "session.new.workspace.existing": "Arbeitsbereich…",
"session.new.git.none": "Kein Git",
"session.new.lastModified": "Zuletzt geändert", "session.new.lastModified": "Zuletzt geändert",
"session.header.search.placeholder": "{{project}} durchsuchen", "session.header.search.placeholder": "{{project}} durchsuchen",
"session.header.searchFiles": "Dateien suchen", "session.header.searchFiles": "Dateien suchen",
-4
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "Stop sharing this session", "command.session.unshare.description": "Stop sharing this session",
"palette.search.placeholder": "Search files, commands, and sessions", "palette.search.placeholder": "Search files, commands, and sessions",
"palette.search.placeholder.home": "Search commands and sessions",
"palette.empty": "No results found", "palette.empty": "No results found",
"palette.group.commands": "Commands", "palette.group.commands": "Commands",
"palette.group.files": "Files", "palette.group.files": "Files",
@@ -102,7 +101,6 @@ export const dict = {
"dialog.provider.empty": "No providers found", "dialog.provider.empty": "No providers found",
"dialog.provider.group.popular": "Popular", "dialog.provider.group.popular": "Popular",
"dialog.provider.group.other": "Other", "dialog.provider.group.other": "Other",
"dialog.provider.custom.label": "Custom OpenAI-compatible provider",
"dialog.provider.tag.recommended": "Recommended", "dialog.provider.tag.recommended": "Recommended",
"dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more", "dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more",
"dialog.provider.opencode.tagline": "Reliable optimized models", "dialog.provider.opencode.tagline": "Reliable optimized models",
@@ -123,7 +121,6 @@ export const dict = {
"dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode", "dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
"dialog.model.unpaid.addMore.title": "Add more models from popular providers", "dialog.model.unpaid.addMore.title": "Add more models from popular providers",
"dialog.model.unpaid.viewMoreProviders": "See 70+ more providers",
"dialog.provider.viewAll": "Show more providers", "dialog.provider.viewAll": "Show more providers",
@@ -700,7 +697,6 @@ export const dict = {
"session.new.workspace.triggerLocal": "Local", "session.new.workspace.triggerLocal": "Local",
"session.new.workspace.local": "Local repository", "session.new.workspace.local": "Local repository",
"session.new.workspace.existing": "Workspace…", "session.new.workspace.existing": "Workspace…",
"session.new.git.none": "No Git",
"session.new.lastModified": "Last modified", "session.new.lastModified": "Last modified",
"session.header.search.placeholder": "Search {{project}}", "session.header.search.placeholder": "Search {{project}}",
-2
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "Dejar de compartir esta sesión", "command.session.unshare.description": "Dejar de compartir esta sesión",
"palette.search.placeholder": "Buscar archivos, comandos y sesiones", "palette.search.placeholder": "Buscar archivos, comandos y sesiones",
"palette.search.placeholder.home": "Buscar comandos y sesiones",
"palette.empty": "No se encontraron resultados", "palette.empty": "No se encontraron resultados",
"palette.group.commands": "Comandos", "palette.group.commands": "Comandos",
"palette.group.files": "Archivos", "palette.group.files": "Archivos",
@@ -676,7 +675,6 @@ export const dict = {
"session.new.workspace.triggerLocal": "Local", "session.new.workspace.triggerLocal": "Local",
"session.new.workspace.local": "Repositorio local", "session.new.workspace.local": "Repositorio local",
"session.new.workspace.existing": "Espacio de trabajo…", "session.new.workspace.existing": "Espacio de trabajo…",
"session.new.git.none": "Sin Git",
"session.new.lastModified": "Última modificación", "session.new.lastModified": "Última modificación",
"session.header.search.placeholder": "Buscar {{project}}", "session.header.search.placeholder": "Buscar {{project}}",
-2
View File
@@ -86,7 +86,6 @@ export const dict = {
"command.session.unshare": "Ne plus partager la session", "command.session.unshare": "Ne plus partager la session",
"command.session.unshare.description": "Arrêter de partager cette session", "command.session.unshare.description": "Arrêter de partager cette session",
"palette.search.placeholder": "Rechercher des fichiers, des commandes et des sessions", "palette.search.placeholder": "Rechercher des fichiers, des commandes et des sessions",
"palette.search.placeholder.home": "Rechercher des commandes et des sessions",
"palette.empty": "Aucun résultat trouvé", "palette.empty": "Aucun résultat trouvé",
"palette.group.commands": "Commandes", "palette.group.commands": "Commandes",
"palette.group.files": "Fichiers", "palette.group.files": "Fichiers",
@@ -624,7 +623,6 @@ export const dict = {
"session.new.workspace.triggerLocal": "Local", "session.new.workspace.triggerLocal": "Local",
"session.new.workspace.local": "Dépôt local", "session.new.workspace.local": "Dépôt local",
"session.new.workspace.existing": "Espace de travail…", "session.new.workspace.existing": "Espace de travail…",
"session.new.git.none": "Pas de Git",
"session.new.lastModified": "Dernière modification", "session.new.lastModified": "Dernière modification",
"session.header.search.placeholder": "Rechercher {{project}}", "session.header.search.placeholder": "Rechercher {{project}}",
"session.header.searchFiles": "Rechercher des fichiers", "session.header.searchFiles": "Rechercher des fichiers",
-2
View File
@@ -86,7 +86,6 @@ export const dict = {
"command.session.unshare": "セッションの共有を停止", "command.session.unshare": "セッションの共有を停止",
"command.session.unshare.description": "このセッションの共有を停止", "command.session.unshare.description": "このセッションの共有を停止",
"palette.search.placeholder": "ファイル、コマンド、セッションを検索", "palette.search.placeholder": "ファイル、コマンド、セッションを検索",
"palette.search.placeholder.home": "コマンドとセッションを検索",
"palette.empty": "結果が見つかりません", "palette.empty": "結果が見つかりません",
"palette.group.commands": "コマンド", "palette.group.commands": "コマンド",
"palette.group.files": "ファイル", "palette.group.files": "ファイル",
@@ -615,7 +614,6 @@ export const dict = {
"session.new.workspace.triggerLocal": "ローカル", "session.new.workspace.triggerLocal": "ローカル",
"session.new.workspace.local": "ローカルリポジトリ", "session.new.workspace.local": "ローカルリポジトリ",
"session.new.workspace.existing": "ワークスペース…", "session.new.workspace.existing": "ワークスペース…",
"session.new.git.none": "Git なし",
"session.new.lastModified": "最終更新", "session.new.lastModified": "最終更新",
"session.header.search.placeholder": "{{project}}を検索", "session.header.search.placeholder": "{{project}}を検索",
"session.header.searchFiles": "ファイルを検索", "session.header.searchFiles": "ファイルを検索",
-2
View File
@@ -82,7 +82,6 @@ export const dict = {
"command.session.unshare": "세션 공유 중지", "command.session.unshare": "세션 공유 중지",
"command.session.unshare.description": "이 세션 공유 중지", "command.session.unshare.description": "이 세션 공유 중지",
"palette.search.placeholder": "파일, 명령어 및 세션 검색", "palette.search.placeholder": "파일, 명령어 및 세션 검색",
"palette.search.placeholder.home": "명령어 및 세션 검색",
"palette.empty": "결과 없음", "palette.empty": "결과 없음",
"palette.group.commands": "명령어", "palette.group.commands": "명령어",
"palette.group.files": "파일", "palette.group.files": "파일",
@@ -976,7 +975,6 @@ export const dict = {
"session.new.workspace.triggerLocal": "로컬", "session.new.workspace.triggerLocal": "로컬",
"session.new.workspace.local": "로컬 저장소", "session.new.workspace.local": "로컬 저장소",
"session.new.workspace.existing": "작업 공간…", "session.new.workspace.existing": "작업 공간…",
"session.new.git.none": "Git 없음",
"sidebar.empty.title": "열린 프로젝트 없음", "sidebar.empty.title": "열린 프로젝트 없음",
"sidebar.empty.description": "프로젝트를 열어 시작하세요", "sidebar.empty.description": "프로젝트를 열어 시작하세요",
-2
View File
@@ -92,7 +92,6 @@ export const dict = {
"command.session.unshare.description": "Slutt å dele denne sesjonen", "command.session.unshare.description": "Slutt å dele denne sesjonen",
"palette.search.placeholder": "Søk i filer, kommandoer og sesjoner", "palette.search.placeholder": "Søk i filer, kommandoer og sesjoner",
"palette.search.placeholder.home": "Søk i kommandoer og sesjoner",
"palette.empty": "Ingen resultater funnet", "palette.empty": "Ingen resultater funnet",
"palette.group.commands": "Kommandoer", "palette.group.commands": "Kommandoer",
"palette.group.files": "Filer", "palette.group.files": "Filer",
@@ -1070,7 +1069,6 @@ export const dict = {
"session.new.workspace.triggerLocal": "Lokalt", "session.new.workspace.triggerLocal": "Lokalt",
"session.new.workspace.local": "Lokalt depot", "session.new.workspace.local": "Lokalt depot",
"session.new.workspace.existing": "Arbeidsområde…", "session.new.workspace.existing": "Arbeidsområde…",
"session.new.git.none": "Ingen Git",
"sidebar.empty.title": "Ingen åpne prosjekter", "sidebar.empty.title": "Ingen åpne prosjekter",
"sidebar.empty.description": "Åpne et prosjekt for å komme i gang", "sidebar.empty.description": "Åpne et prosjekt for å komme i gang",

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