Compare commits

..

68 Commits

Author SHA1 Message Date
usrnk1 4100018ec8 Merge remote-tracking branch 'origin/dev' into tools-menu
# Conflicts:
#	packages/app/src/components/status-popover.tsx
2026-07-14 11:14:28 +02:00
usrnk1 f69c29b38c fix(app): simplify server status indicators 2026-07-14 10:55:15 +02:00
usrnk1 11d2eb3ec8 feat(app): add server connection indicator 2026-07-14 10:33:01 +02:00
opencode-agent[bot] cb8be9ba12 chore: generate 2026-07-14 03:38:03 +00:00
David Hill 15046658ba feat(app): update tabs intro content (#36701)
Co-authored-by: Jay <air@live.ca>
2026-07-14 11:36:22 +08:00
opencode-agent[bot] 7255ce9a57 chore: update nix node_modules hashes 2026-07-14 00:23:00 +00:00
opencode-agent[bot] 2b9cf5d388 chore: generate 2026-07-14 00:05:56 +00:00
Luke Parker a625d35f7b fix(app): preserve composer caret after requests (#36503) 2026-07-14 10:04:11 +10:00
Luke Parker 449c64928b fix(app): 78x faster Home cold loading (#36214) 2026-07-14 10:03:51 +10:00
Luke Parker 35c88c3fc8 fix(app): preserve timeline bottom anchoring (#36160) 2026-07-14 10:03:18 +10:00
Luke Parker b5e09024d5 fix(app): clarify status indicator severity (#36031) 2026-07-14 10:02:46 +10:00
Luke Parker 9a51765bd2 fix(ui): preserve code spans adjacent to tildes (#35835) 2026-07-14 10:02:30 +10:00
opencode bb31c9b92c sync release versions for v1.17.20 2026-07-13 21:09:53 +00:00
Aiden Cline 5117679148 fix(opencode): remove obsolete Luna Responses Lite workaround (#36750) 2026-07-13 15:29:41 -05:00
opencode-agent[bot] 0d5798c0bf chore: update nix node_modules hashes 2026-07-13 19:05:41 +00:00
Vladimir Glafirov 808677a612 chore: bump gitlab-ai-provider to 6.11.1 (#36722) 2026-07-13 13:47:42 -05:00
opencode-agent[bot] fe41e9d482 chore: update nix node_modules hashes 2026-07-13 18:16:33 +00:00
opencode-agent[bot] 0768c4655e chore: generate 2026-07-13 18:01:24 +00:00
Adam 1586a26594 fix(data): selects 2026-07-13 12:59:37 -05:00
Adam f026c1422c fix(data): select component ux 2026-07-13 12:59:37 -05:00
opencode-agent[bot] b7fdb595df chore: update nix node_modules hashes 2026-07-13 17:27:30 +00:00
Aiden Cline 049ee1ce5e chore(provider): bump Azure AI SDK for GPT-5.6 (#36704) 2026-07-13 12:10:27 -05:00
opencode e71fbb6d48 sync release versions for v1.17.19 2026-07-13 16:34:54 +00:00
opencode-agent[bot] c9976d69c0 chore: update nix node_modules hashes 2026-07-13 16:22:51 +00:00
opencode-agent[bot] 27734409e4 chore: generate 2026-07-13 16:06:35 +00:00
Aiden Cline e434ce01d3 fix(provider): support OpenAI pro reasoning mode (#36694) 2026-07-13 11:04:46 -05:00
opencode-agent[bot] c2f93ae2ad chore: generate 2026-07-13 15:55:09 +00:00
Adam 92cc8f553a fix(stats): correct breadcrumb behavior 2026-07-13 10:53:11 -05:00
Mark 49d997aec3 fix(xai): default store to false for Responses (#36629)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-13 10:37:12 -05:00
Aiden Cline 67aa9cee23 fix(opencode): support Luna Responses Lite over OAuth (#36685)
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
2026-07-13 10:15:06 -05:00
opencode-agent[bot] 7ec3d67829 chore: update nix node_modules hashes 2026-07-13 14:50:37 +00:00
Vladimir Glafirov 5401ebaede chore: bump gitlab-ai-provider to 6.11.0 (#36668) 2026-07-13 09:31:20 -05:00
Aiden Cline 99668cfdce fix(provider): tweak reasoning option driven max budget variants (#36626) 2026-07-13 09:18:32 -05:00
opencode-agent[bot] b3a012cbdd fix(cli): switch org after console logout (#36276)
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
2026-07-13 10:59:16 +00:00
usrnk1 51b9c726cf fix(app): remove interface transition changes accidentally merged into dev (#36653) 2026-07-13 10:38:30 +00:00
Aarav Sareen b4e49d5b32 feat(app): redesign attachment cards (#35945)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-13 10:04:45 +00:00
opencode-agent[bot] 2b29854589 chore: generate 2026-07-13 10:04:14 +00:00
usrnk1 8ce5038bd8 Merge branch 'dev' of https://github.com/anomalyco/opencode into interface-toggle 2026-07-13 12:02:25 +02:00
usrnk1 446510a6a9 tui: surface a dismissible new-interface notice and tighten settings copy for clarity
Users who were migrated to the new interface will now see a dismissible notice in Settings explaining the old interface was phased out, so they are not surprised by the change. The new-interface toggle is also visually elevated with a card style so it is easier to find. Settings labels and descriptions across General have been rewritten to be shorter and more direct, so it is quicker to understand what each option controls. The "New" badge color is also adjusted for better readability.
2026-07-13 12:01:19 +02:00
opencode-agent[bot] 49028b6245 chore: generate 2026-07-13 09:33:54 +00:00
Aarav Sareen d595c7e265 feat(app): review panel updates (#36240)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-13 09:32:25 +00:00
Aarav Sareen 17cd4a8c32 feat(app): align edit project modal with v2 style (#36213)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-13 09:26:53 +00:00
opencode-agent[bot] 9bbf7918de chore: generate 2026-07-13 09:25:14 +00:00
Aarav Sareen 97f502e79a feat(app): middle click to open new tab (#36215) 2026-07-13 17:23:55 +08:00
usrnk1 ac5e249083 feat(app): add interface transition setting 2026-07-13 10:45:57 +02:00
usrnk1 140e796b21 feat(ui): add accent badge variant 2026-07-13 10:45:57 +02:00
usrnk1 dd02cea9e7 feat(desktop): fix clipped labels and branch tooltip (#35724)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-13 08:02:38 +00:00
Aiden Cline a8062ea314 fix(provider): derive variants from reasoning metadata (#36624) 2026-07-13 01:21:59 -05:00
Aiden Cline f47684787a fix(opencode): filter unsupported GPT-5.6 OAuth alias (#36621) 2026-07-13 00:23:28 -05:00
Aiden Cline 8168f0f0f6 fix(provider): route gateway variants by api id (#36614) 2026-07-13 00:08:08 -05:00
opencode-agent[bot] 8036440693 ci: remove starptech from core triage assignees (#36618)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-12 23:41:02 -05:00
opencode-agent[bot] cf7503687a chore: generate 2026-07-12 21:13:21 +00:00
Aiden Cline 6f8e1dda15 fix(provider): derive variants from reasoning options (#36543) 2026-07-12 16:12:07 -05:00
opencode-agent[bot] 4dcfd9182c chore: generate 2026-07-12 19:42:58 +00:00
Nabs d7c0db8cee fix(openai): use codex context limits for gpt-5.6 (#36248)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-12 14:41:51 -05:00
Aiden Cline 184da0e42e test(opencode): refresh stale model references (#36546) 2026-07-12 13:28:36 -05:00
Aiden Cline a244d82aba test(opencode): refresh models.dev fixture (#36541) 2026-07-12 12:36:20 -05:00
usrnk1 014696db63 Wip commit 2026-07-10 14:09:37 +02:00
usrnk1 351172dd1b docs(app): clarify tools menu setting 2026-07-10 11:40:34 +02:00
usrnk1 50f8938960 fix(app): respect tools visibility setting 2026-07-10 11:33:54 +02:00
usrnk1 23bc29fde5 fix(app): preserve mcp switch transition 2026-07-10 11:10:48 +02:00
usrnk1 2a1565951f fix(app): avoid disabled cursor during mcp toggle 2026-07-10 11:08:23 +02:00
usrnk1 4db321512f fix(app): hide tools menu on new session 2026-07-10 11:05:10 +02:00
usrnk1 ecaaceeecf Merge branch 'dev' into tools-menu 2026-07-10 10:38:25 +02:00
usrnk1 249bbd599c fix(ui): prevent segmented label clipping 2026-07-09 16:04:49 +02:00
usrnk1 2aeeca5243 fix(ui): respect reduced motion in popovers 2026-07-09 16:04:49 +02:00
usrnk1 c8f4c383cd test(app): add tools menu stories 2026-07-09 16:04:41 +02:00
usrnk1 25ee2435f6 feat(app): replace status menu with tools 2026-07-09 16:04:35 +02:00
166 changed files with 171708 additions and 111767 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
const TEAM = {
tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom", "starptech"],
windows: ["Hona"],
} as const
+83 -49
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -95,7 +95,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.17.18",
"version": "1.17.20",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -143,7 +143,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -157,7 +157,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -193,7 +193,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -220,7 +220,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -242,7 +242,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -266,7 +266,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -286,7 +286,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.17.18",
"version": "1.17.20",
"bin": {
"opencode": "./bin/opencode",
},
@@ -294,7 +294,7 @@
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/azure": "3.0.88",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
@@ -303,7 +303,7 @@
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
@@ -338,7 +338,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.10.0",
"gitlab-ai-provider": "6.11.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -380,7 +380,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -434,7 +434,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -448,7 +448,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"effect": "catalog:",
},
@@ -460,7 +460,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -492,7 +492,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -508,7 +508,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -539,7 +539,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -558,7 +558,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.18",
"version": "1.17.20",
"bin": {
"opencode": "./bin/opencode",
},
@@ -569,7 +569,7 @@
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/azure": "3.0.88",
"@ai-sdk/cerebras": "2.0.60",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
@@ -578,7 +578,7 @@
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
@@ -631,7 +631,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.10.0",
"gitlab-ai-provider": "6.11.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -689,7 +689,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -765,7 +765,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -780,7 +780,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -795,7 +795,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -839,7 +839,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -852,9 +852,10 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -885,7 +886,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -904,7 +905,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -945,7 +946,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -972,7 +973,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1023,7 +1024,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1066,6 +1067,7 @@
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
@@ -1073,10 +1075,8 @@
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
},
"overrides": {
@@ -1112,7 +1112,7 @@
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@tailwindcss/vite": "4.1.11",
"@tanstack/solid-virtual": "3.13.28",
"@tanstack/solid-virtual": "3.13.32",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.13",
@@ -1132,7 +1132,7 @@
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"luxon": "3.6.1",
"marked": "17.0.1",
"marked": "17.0.6",
"marked-shiki": "1.2.1",
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
@@ -1174,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/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/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/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=="],
@@ -1184,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/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/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/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=="],
@@ -2780,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-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/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/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
"@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=="],
@@ -3850,7 +3850,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"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=="],
"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=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -4312,7 +4312,7 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
@@ -5654,7 +5654,11 @@
"@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/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/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"@ai-sdk/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=="],
@@ -5666,9 +5670,9 @@
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@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-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/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/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
@@ -6020,7 +6024,7 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@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": ["@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-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=="],
@@ -6046,6 +6050,8 @@
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
@@ -6162,6 +6168,10 @@
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
"ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
"ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
@@ -6388,7 +6398,7 @@
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.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": ["@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-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -6896,6 +6906,10 @@
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
@@ -6966,6 +6980,14 @@
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -7118,6 +7140,10 @@
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -7366,6 +7392,8 @@
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
@@ -7382,6 +7410,10 @@
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
@@ -7452,6 +7484,8 @@
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=",
"aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=",
"aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=",
"x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk="
"x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=",
"aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=",
"aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=",
"x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw="
}
}
+4 -5
View File
@@ -43,7 +43,7 @@
"@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.3",
"@tanstack/solid-virtual": "3.13.28",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
@@ -70,7 +70,7 @@
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"marked": "17.0.1",
"marked": "17.0.6",
"marked-shiki": "1.2.1",
"remend": "1.3.0",
"@playwright/test": "1.59.1",
@@ -152,10 +152,9 @@
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
}
}
@@ -0,0 +1,150 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/FileBrowserSidebar"
const projectID = "proj_file_browser_sidebar"
const sessionID = "ses_file_browser_sidebar"
const title = "File browser sidebar"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`)
// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable.
const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// The file-browser sidebar must stay mounted across preview/pinned file-tab
// switches. Remounting resets scroll and filter state.
test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => {
await setup(page)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
await expect(sidebar).toBeVisible()
await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible()
await panel.getByRole("button", { name: "file-00.ts" }).click()
await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible()
const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await viewport.hover()
await page.mouse.wheel(0, 100_000)
await expect
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
const scrolled = await viewport.evaluate((element) => element.scrollTop)
expect(scrolled).toBeGreaterThan(0)
await writeProbe(page)
await panel.getByRole("button", { name: "file-79.ts" }).click()
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible()
expect(await readProbe(page)).toBe(PROBE)
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
await panel.getByRole("button", { name: "file-78.ts" }).dblclick()
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "file-79.ts" }).click()
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
await panel.getByRole("tab", { name: "file-78.ts" }).click()
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
expect(await readProbe(page)).toBe(PROBE)
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
})
type Probed = HTMLElement & { __e2eProbe?: string }
async function writeProbe(page: Page) {
await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => {
;(el as Probed).__e2eProbe = probe
}, PROBE)
}
async function readProbe(page: Page) {
return page
.locator('#review-panel [data-component="session-review-v2-sidebar-root"]')
.evaluate((el) => (el as Probed).__e2eProbe)
}
async function setup(page: Page) {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "file-browser-sidebar",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: sessionID,
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [],
fileList: (path) => {
if (path) return []
return files.map((name) => ({
name,
path: name,
absolute: `${directory}/${name}`,
type: "file" as const,
ignored: false,
}))
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
)
}
@@ -91,15 +91,17 @@ test("opens and searches project files inline", async ({ page }) => {
const panel = page.locator("#review-panel")
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled()
await expect(sidebar).toBeVisible()
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toHaveCount(0)
await expect(sidebar).toBeHidden()
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })
await expect(filter).toBeFocused()
@@ -108,6 +110,7 @@ test("opens and searches project files inline", async ({ page }) => {
await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
@@ -122,12 +125,17 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled()
await panel.getByRole("tab", { name: /Review/ }).click()
await expect(sidebarToggle).toBeEnabled()
await panel.getByRole("tab", { name: "Open file" }).click()
await page.keyboard.press("Control+w")
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
@@ -28,8 +28,8 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
const reviewTab = page.getByRole("tab", { name: /Review/ })
const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ })
const reviewTab = page.locator("#session-side-panel-review-tab")
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
const review = page.locator('#review-panel [data-component="session-review-v2"]')
@@ -111,7 +111,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
await expectTree(page, 8, "git-0.ts")
await selectMode(page, "Git changes", "Branch changes")
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
@@ -1,6 +1,7 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/RequestDocks"
@@ -100,6 +101,67 @@ test("shows a pending permission dock", async ({ page }) => {
expect(request.postDataJSON()).toEqual({ response: "once" })
})
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
const transport = await installSseTransport(page, {
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
retry: 20,
})
await mockServer(page, { questions: [] })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection()
await expectSessionTitle(page, title)
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
const draft = "keep the caret at the end"
await editor.fill(draft)
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft")
const cursor = draft.length - 4
await expect
.poll(() =>
editor.evaluate((element) => {
const selection = window.getSelection()
if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1
const range = selection.getRangeAt(0).cloneRange()
range.selectNodeContents(element)
range.setEnd(selection.anchorNode!, selection.anchorOffset)
return range.toString().length
}),
)
.toBe(cursor)
await transport.send({
directory,
payload: {
type: "question.asked",
properties: {
id: "question-caret",
sessionID,
questions: [
{
header: "Continue",
question: "Continue?",
options: [{ label: "Yes", description: "Continue the session" }],
},
],
tool: { messageID: "message-caret", callID: "call-caret" },
},
},
})
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question).toBeVisible()
await expect(editor).toHaveCount(0)
await transport.send({
directory,
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
})
await expect(question).toHaveCount(0)
await expect(editor).toBeVisible()
await page.keyboard.press("x")
await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`)
})
async function mockServer(
page: Page,
requests: {
@@ -152,7 +152,10 @@ test.describe("session timeline projection", () => {
parentID: "msg_2000_diff_next_user",
created: 1700000011000,
})
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
await setupTimeline(page, {
messages: [user, assistantMessage(), nextUser, nextAssistant],
settings: { newLayoutDesigns: false },
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
+29
View File
@@ -56,6 +56,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
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 === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
@@ -132,6 +137,30 @@ 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) {
return route.fulfill({
status,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.18",
"version": "1.17.20",
"description": "",
"type": "module",
"exports": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

@@ -0,0 +1,156 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Field } from "@opencode-ai/ui/v2/field-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { createEditProjectModel } from "./edit-project"
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
const language = useLanguage()
const model = createEditProjectModel(props)
return (
<Dialog fit>
<form onSubmit={model.submit} class="contents">
<DialogHeader>
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
<Field>
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
<TextInputV2
autofocus
appearance="large"
class="!w-full"
value={model.store.name}
placeholder={model.folderName()}
onInput={(event) => model.setStore("name", event.currentTarget.value)}
/>
</Field>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.icon")}
</div>
<div class="flex items-center gap-3">
<button
type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
}}
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
src={getProjectAvatarSource(props.project.id, {
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
})}
variant={getProjectAvatarVariant(model.store.color)}
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
/>
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
classList={{
"opacity-100": model.store.iconHover,
"opacity-0": !model.store.iconHover,
}}
>
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
</span>
</button>
<input
ref={(element) => {
model.setIconInput(element)
}}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
/>
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
</div>
<Show when={!model.store.iconOverride}>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.color")}
</div>
<div class="-ml-1 flex gap-1.5">
<For each={PROJECT_AVATAR_VARIANTS}>
{(color) => (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
getProjectAvatarVariant(model.store.color) === color,
}}
onClick={() => {
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
model.setStore(
"color",
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
)
}}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
variant={getProjectAvatarVariant(color)}
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)}
</For>
</div>
</div>
</Show>
<Field>
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
<TextareaV2
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={3}
value={model.store.startup}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
spellcheck={false}
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
/>
</Field>
</DialogBody>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</ButtonV2>
</DialogFooter>
</form>
</Dialog>
)
}
@@ -1,123 +1,32 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { For, Show } from "solid-js"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/core/util/path"
import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global"
import { createEditProjectModel } from "./edit-project"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
const dialog = useDialog()
const global = useGlobal()
const language = useLanguage()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
const serverSDK = () => serverCtx().sdk
const serverSync = () => serverCtx().sync
const folderName = createMemo(() => getFilename(props.project.worktree))
const defaultName = createMemo(() => props.project.name || folderName())
const [store, setStore] = createStore({
name: defaultName(),
color: props.project.icon?.color,
iconOverride: props.project.icon?.override,
startup: props.project.commands?.start ?? "",
dragOver: false,
iconHover: false,
})
let iconInput: HTMLInputElement | undefined
function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
setStore("iconOverride", e.target?.result as string)
setStore("iconHover", false)
}
reader.readAsDataURL(file)
}
function handleDrop(e: DragEvent) {
e.preventDefault()
setStore("dragOver", false)
const file = e.dataTransfer?.files[0]
if (file) handleFileSelect(file)
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
setStore("dragOver", true)
}
function handleDragLeave() {
setStore("dragOver", false)
}
function handleInputChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (file) handleFileSelect(file)
}
function clearIcon() {
setStore("iconOverride", "")
}
const saveMutation = useMutation(() => ({
mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await serverSDK().client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
serverSync().project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
dialog.close()
},
}))
function handleSubmit(e: SubmitEvent) {
e.preventDefault()
if (saveMutation.isPending) return
saveMutation.mutate()
}
const model = createEditProjectModel(props)
return (
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
<div class="flex flex-col gap-4">
<TextField
autofocus
type="text"
label={language.t("dialog.project.edit.name")}
placeholder={folderName()}
value={store.name}
onChange={(v) => setStore("name", v)}
placeholder={model.folderName()}
value={model.store.name}
onChange={(v) => model.setStore("name", v)}
/>
<div class="flex flex-col gap-2">
@@ -125,38 +34,32 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<div class="flex gap-3 items-start">
<div
class="relative"
onMouseEnter={() => setStore("iconHover", true)}
onMouseLeave={() => setStore("iconHover", false)}
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
>
<div
class="relative size-16 rounded-md transition-colors cursor-pointer"
classList={{
"border-text-interactive-base bg-surface-info-base/20": store.dragOver,
"border-border-base hover:border-border-strong": !store.dragOver,
"overflow-hidden": !!store.iconOverride,
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => {
if (store.iconOverride && store.iconHover) {
clearIcon()
} else {
iconInput?.click()
}
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
"border-border-base hover:border-border-strong": !model.store.dragOver,
"overflow-hidden": !!model.store.iconOverride,
}}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<Show
when={getProjectAvatarSource(props.project.id, {
color: store.color,
color: model.store.color,
url: props.project.icon?.url,
override: store.iconOverride,
override: model.store.iconOverride,
})}
fallback={
<div class="size-full flex items-center justify-center">
<Avatar
fallback={store.name || defaultName()}
{...getAvatarColors(store.color)}
fallback={model.store.name || model.defaultName()}
{...getAvatarColors(model.store.color)}
class="size-full text-[32px]"
/>
</div>
@@ -174,8 +77,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": store.iconHover && !store.iconOverride,
"opacity-0": !(store.iconHover && !store.iconOverride),
"opacity-100": model.store.iconHover && !model.store.iconOverride,
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
}}
>
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
@@ -183,8 +86,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": store.iconHover && !!store.iconOverride,
"opacity-0": !(store.iconHover && !!store.iconOverride),
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
}}
>
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
@@ -193,12 +96,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<input
id="icon-upload"
ref={(el) => {
iconInput = el
model.setIconInput(el)
}}
type="file"
accept="image/*"
class="hidden"
onChange={handleInputChange}
onChange={model.inputChange}
/>
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
@@ -207,7 +110,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
</div>
</div>
<Show when={!store.iconOverride}>
<Show when={!model.store.iconOverride}>
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
<div class="flex gap-1.5">
@@ -216,21 +119,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={store.color === color}
aria-pressed={model.store.color === color}
classList={{
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
store.color === color,
model.store.color === color,
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
store.color !== color,
model.store.color !== color,
}}
onClick={() => {
if (store.color === color && !props.project.icon?.url) return
setStore("color", store.color === color ? undefined : color)
if (model.store.color === color && !props.project.icon?.url) return
model.setStore("color", model.store.color === color ? undefined : color)
}}
>
<Avatar
fallback={store.name || defaultName()}
fallback={model.store.name || model.defaultName()}
{...getAvatarColors(color)}
class="size-full rounded"
/>
@@ -246,19 +149,19 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
label={language.t("dialog.project.edit.worktree.startup")}
description={language.t("dialog.project.edit.worktree.startup.description")}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
value={store.startup}
onChange={(v) => setStore("startup", v)}
value={model.store.startup}
onChange={(v) => model.setStore("startup", v)}
spellcheck={false}
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
/>
</div>
<div class="flex justify-end gap-2">
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
<Button type="button" variant="ghost" size="large" onClick={model.close}>
{language.t("common.cancel")}
</Button>
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</Button>
</div>
</form>
+119
View File
@@ -0,0 +1,119 @@
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global"
import { type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const dialog = useDialog()
const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
const folderName = createMemo(() => getFilename(props.project.worktree))
const defaultName = createMemo(() => props.project.name || folderName())
const [store, setStore] = createStore({
name: defaultName(),
color: props.project.icon?.color,
iconOverride: props.project.icon?.override,
startup: props.project.commands?.start ?? "",
dragOver: false,
iconHover: false,
})
let iconInput: HTMLInputElement | undefined
function selectFile(file: File) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (event) => {
const result = event.target?.result
if (typeof result !== "string") return
setStore("iconOverride", result)
setStore("iconHover", false)
}
reader.readAsDataURL(file)
}
function drop(event: DragEvent) {
event.preventDefault()
setStore("dragOver", false)
const file = event.dataTransfer?.files[0]
if (file) selectFile(file)
}
function dragOver(event: DragEvent) {
event.preventDefault()
setStore("dragOver", true)
}
function dragLeave() {
setStore("dragOver", false)
}
function inputChange(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0]
if (file) selectFile(file)
}
function iconClick() {
if (store.iconOverride && store.iconHover) {
setStore("iconOverride", "")
return
}
iconInput?.click()
}
const save = useMutation(() => ({
mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await serverCtx().sdk.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
serverCtx().sync.project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
dialog.close()
},
}))
function submit(event: SubmitEvent) {
event.preventDefault()
if (save.isPending) return
save.mutate()
}
return {
store,
setStore,
folderName,
defaultName,
save,
submit,
drop,
dragOver,
dragLeave,
inputChange,
iconClick,
close() {
dialog.close()
},
setIconInput(input: HTMLInputElement) {
iconInput = input
},
}
}
@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test"
import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model"
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
import type { FileNode } from "@opencode-ai/sdk/v2"
describe("file tree v2 model", () => {
test("builds sorted depth-first rows", () => {
describe("buildFileTreeV2Model", () => {
test("builds a sorted tree and flattens expanded directories", () => {
const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
expect(model.total).toBe(8)
@@ -18,7 +19,7 @@ describe("file tree v2 model", () => {
])
})
test("omits descendants of collapsed directories", () => {
test("skips children of collapsed directories", () => {
const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
@@ -28,19 +29,46 @@ describe("file tree v2 model", () => {
])
})
test("normalizes separators and duplicate paths", () => {
test("normalizes duplicate and messy paths", () => {
const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
const rows = flattenFileTreeV2(model, () => true)
expect(model.total).toBe(4)
expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
})
test("supports paths deeper than the legacy recursion limit", () => {
const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts`
test("handles deeply nested paths", () => {
const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts"
const model = buildFileTreeV2Model([file])
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
})
})
describe("flattenLiveFileTreeV2", () => {
test("flattens live children using original paths for nested lookups", () => {
const nodes: Record<string, FileNode[]> = {
"": [
{ name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false },
{ name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false },
],
src: [
{ name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false },
{ name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false },
],
"src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }],
}
expect(
flattenLiveFileTreeV2(
(path) => nodes[path] ?? [],
(path) => path === "src",
).map((row) => [row.node.path, row.node.originalPath, row.level]),
).toEqual([
["src", "src", 0],
["src/a.ts", "src/a.ts", 1],
["src/lib", "src/lib", 1],
["README.md", "README.md", 0],
])
})
})
@@ -75,3 +75,33 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin
return rows
}
export function flattenLiveFileTreeV2(
children: (path: string) => readonly FileNode[],
expanded: (path: string) => boolean,
) {
const rows: FileTreeV2Row[] = []
const stack = children("")
.toReversed()
.map((node) => ({ node: toLiveNode(node), level: 0 }))
while (stack.length > 0) {
const row = stack.pop()!
rows.push(row)
if (row.node.type !== "directory" || !expanded(row.node.path)) continue
const nested = children(row.node.originalPath)
for (let index = nested.length - 1; index >= 0; index--) {
stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 })
}
}
return rows
}
function toLiveNode(node: FileNode): FileTreeV2Node {
return {
...node,
path: normalizeFileTreeV2Path(node.path),
originalPath: node.path,
}
}
+60 -29
View File
@@ -16,7 +16,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
import {
buildFileTreeV2Model,
flattenFileTreeV2,
flattenLiveFileTreeV2,
normalizeFileTreeV2Path,
type FileTreeV2Node,
} from "@/components/file-tree-v2-model"
import { virtualScrollElement } from "@/components/virtual-scroll-element"
export type { Kind } from "@/components/file-tree"
@@ -36,7 +42,7 @@ function guideLineLeft(level: number) {
export const kindLabel = (kind: Kind) => {
if (kind === "add") return "A"
if (kind === "del") return "D"
return ""
return "M"
}
export const kindChange = (kind: Kind) => {
@@ -68,7 +74,7 @@ const FileTreeNodeV2 = (
"class",
"classList",
])
const kind = () => local.kinds?.get(local.node.path)
const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path))
return (
<Dynamic
@@ -110,12 +116,7 @@ const FileTreeNodeV2 = (
function GuideLines(props: { level: number }) {
return (
<For each={Array.from({ length: props.level })}>
{(_, index) => (
<div
class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
style={`left: ${guideLineLeft(index())}px`}
/>
)}
{(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />}
</For>
)
}
@@ -126,12 +127,18 @@ export default function FileTreeV2(props: {
kinds?: ReadonlyMap<string, Kind>
draggable?: boolean
onFileClick?: (file: FileNode) => void
onFileDoubleClick?: (file: FileNode) => void
}) {
const file = useFile()
const live = () => props.allowed === undefined
const draggable = () => props.draggable ?? true
const active = () => normalizeFileTreeV2Path(props.active ?? "")
const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? []))
const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true))
const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? [])))
const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live()
const rows = createMemo(() => {
if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded)
return flattenFileTreeV2(model()!, expanded)
})
const [root, setRoot] = createSignal<HTMLDivElement>()
const [focused, setFocused] = createSignal<string>()
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
@@ -155,16 +162,49 @@ export default function FileTreeV2(props: {
return [...indexes, index].sort((a, b) => a - b)
},
})
createEffect(() => {
if (!live()) return
void file.tree.list("")
})
// Only scroll when the active path changes (or first appears in the tree).
// Do not re-scroll when expand/collapse reshuffles `rows()`.
let scrolledActive: string | undefined
createEffect(() => {
const path = active()
if (!path) return
if (!path) {
scrolledActive = undefined
return
}
const index = rows().findIndex((row) => row.node.path === path)
if (index < 0) return
if (scrolledActive === path) return
scrolledActive = path
queueMicrotask(() => {
if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
virtualizer.scrollToIndex(index, { align: "auto" })
const next = rows().findIndex((row) => row.node.path === path)
if (next < 0) return
if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return
virtualizer.scrollToIndex(next, { align: "auto" })
})
})
const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => {
action?.({
...node,
path: node.originalPath,
absolute: node.originalPath,
})
}
const toggleDirectory = (path: string, originalPath: string) => {
if (expanded(path)) {
file.tree.collapse(originalPath)
return
}
file.tree.expand(originalPath, live() ? undefined : { list: false })
}
const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
@@ -175,7 +215,7 @@ export default function FileTreeV2(props: {
<div
ref={setRoot}
data-component="file-tree-v2"
data-total-rows={model().total}
data-total-rows={live() ? rows().length : model()!.total}
class="group/file-tree-v2"
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
>
@@ -209,13 +249,8 @@ export default function FileTreeV2(props: {
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() =>
props.onFileClick?.({
...row().node,
path: row().node.originalPath,
absolute: row().node.originalPath,
})
}
onClick={() => selectFile(row().node, props.onFileClick)}
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
@@ -239,17 +274,13 @@ export default function FileTreeV2(props: {
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
aria-expanded={file.tree.state(row().node.path)?.expanded ?? true}
onClick={() =>
file.tree.state(row().node.path)?.expanded === false
? file.tree.expand(row().node.path, { list: false })
: file.tree.collapse(row().node.path)
}
aria-expanded={expanded(row().node.path)}
onClick={() => toggleDirectory(row().node.path, row().node.originalPath)}
>
<GuideLines level={row().level} />
<div
data-slot="file-tree-v2-chevron"
data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
data-expanded={expanded(row().node.path) ? "" : undefined}
class="size-4 flex items-center justify-center"
>
<Icon name="chevron-down" />
+28 -10
View File
@@ -5,6 +5,8 @@ import { createStore } from "solid-js/store"
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
import { usePlatform } from "@/context/platform"
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.png"
import { Persist, persisted } from "@/utils/persist"
const helpIcon = (
@@ -65,7 +67,7 @@ export function TabsInfoPopup() {
<Show when={!state.dismissed}>
<div
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label="Introducing Tabs. A faster, more intuitive way to work."
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
>
<button
type="button"
@@ -107,7 +109,7 @@ export function TabsInfoPopup() {
Introducing Tabs
</p>
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
A faster, more intuitive way to work.
Organize your work and active sessions with tabs
</p>
</div>
</button>
@@ -127,16 +129,32 @@ export function TabsInfoPopup() {
icon={<IconV2 name="xmark-small" />}
/>
</div>
<div class="relative flex w-full flex-col items-start gap-6 p-8">
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
Introducing Tabs 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.
Introducing Tabs
</p>
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<p>OpenCode Desktop is now built around tabs.</p>
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
you're starting something new, and close it when you're done.
</p>
<p>
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
memorable if you plan to keep them around.
</p>
<p>
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
</p>
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>When you reopen the app, your tabs are still open.</p>
<p>
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
the previous layout , you can switch between layouts in Settings. Just keep in mind that the new layout
will become permanent in a few weeks.
</p>
</div>
</div>
</DrawerContent>
</Drawer>
+20 -3
View File
@@ -19,6 +19,7 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/f
import {
ContentPart,
DEFAULT_PROMPT,
isCommentItem,
isPromptEqual,
Prompt,
usePrompt,
@@ -632,7 +633,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const handleBlur = () => {
savedCursor = currentCursor()
const cursor = currentCursor()
savedCursor = cursor
if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor)
closePopover()
setComposing(false)
}
@@ -1626,7 +1629,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)}
/>
<PromptContextItems
items={contextItems()}
items={contextItems().filter((item) => !isCommentItem(item))}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
@@ -1636,6 +1639,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
newLayoutDesigns={props.controls.newLayoutDesigns}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<PromptImageAttachments
@@ -1645,6 +1649,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
newLayoutDesigns={props.controls.newLayoutDesigns}
comments={contextItems().filter(isCommentItem)}
commentActive={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
}}
onOpenComment={openComment}
onRemoveComment={(item) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
/>
<div
class="relative min-h-[52px]"
@@ -1852,6 +1867,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
newLayoutDesigns={props.controls.newLayoutDesigns}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<PromptImageAttachments
@@ -1861,6 +1877,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
newLayoutDesigns={props.controls.newLayoutDesigns}
/>
<div
class="relative"
@@ -2341,7 +2358,7 @@ function ModelControlContent(props: { state: ComposerModelControlState; v2?: boo
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<span class="truncate leading-4">{props.state.modelName}</span>
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
@@ -1,6 +1,8 @@
import { Component, For, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
import type { ContextItem } from "@/context/prompt"
@@ -12,6 +14,7 @@ type ContextItemsProps = {
active: (item: PromptContextItem) => boolean
openComment: (item: PromptContextItem) => void
remove: (item: PromptContextItem) => void
newLayoutDesigns: boolean
t: (key: string) => string
}
@@ -27,10 +30,17 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
const selected = props.active(item)
return (
<TooltipV2
<Dynamic
component={props.newLayoutDesigns ? TooltipV2 : Tooltip}
value={
<span class="flex max-w-[300px]">
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
<span
classList={{
"truncate-start [unicode-bidi:plaintext] min-w-0": true,
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-invert-base": !props.newLayoutDesigns,
}}
>
{directory}
</span>
<span class="shrink-0">{filename}</span>
@@ -78,7 +88,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
</Show>
</div>
</TooltipV2>
</Dynamic>
)
}}
</For>
@@ -0,0 +1,43 @@
@keyframes prompt-attachments-fade-left {
from {
visibility: hidden;
}
to {
visibility: visible;
}
}
@keyframes prompt-attachments-fade-right {
from {
visibility: visible;
}
to {
visibility: hidden;
}
}
[data-slot="prompt-attachments"] {
timeline-scope: --prompt-attachments-scroll;
[data-slot^="prompt-attachments-fade-"] {
visibility: hidden;
}
}
@supports (animation-timeline: --prompt-attachments-scroll) and (timeline-scope: --prompt-attachments-scroll) {
[data-slot="prompt-attachments-scroll"] {
scroll-timeline: --prompt-attachments-scroll x;
}
[data-slot="prompt-attachments-fade-left"] {
animation: prompt-attachments-fade-left linear both;
animation-timeline: --prompt-attachments-scroll;
animation-range: 0 0.1px;
}
[data-slot="prompt-attachments-fade-right"] {
animation: prompt-attachments-fade-right linear both;
animation-timeline: --prompt-attachments-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
}
}
@@ -1,60 +1,167 @@
import { Component, For, Show } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import type { ImageAttachmentPart } from "@/context/prompt"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { AttachmentCardV2 } from "@opencode-ai/session-ui/v2/attachment-card-v2"
import { CommentCardV2 } from "@opencode-ai/session-ui/v2/comment-card-v2"
import { typeLabel } from "@opencode-ai/session-ui/message-file"
import type { ContextItem, ImageAttachmentPart } from "@/context/prompt"
import "./image-attachments.css"
type PromptCommentItem = ContextItem & { key: string }
type PromptImageAttachmentsProps = {
attachments: ImageAttachmentPart[]
onOpen: (attachment: ImageAttachmentPart) => void
onRemove: (id: string) => void
removeLabel: string
newLayoutDesigns: boolean
comments?: PromptCommentItem[]
commentActive?: (item: PromptCommentItem) => boolean
onOpenComment?: (item: PromptCommentItem) => void
onRemoveComment?: (item: PromptCommentItem) => void
}
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
const imageClass =
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
const imageClassV2 = "w-[58px] h-[46px] rounded-[6px] object-cover"
// inset box-shadows do not paint over <img> content, so the hairline is a separate overlay
const imageHairlineClassV2 =
"absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none"
const removeClass =
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
const removeClassV2 =
"absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
return (
<Show when={props.attachments.length > 0}>
<div class="flex flex-wrap gap-2 px-3 pt-3">
<For each={props.attachments}>
{(attachment) => (
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
<div class="relative group">
<Show when={props.attachments.length > 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}>
<div data-slot="prompt-attachments" classList={{ relative: props.newLayoutDesigns }}>
<div
data-slot="prompt-attachments-scroll"
classList={{
"flex gap-2": true,
"flex-nowrap overflow-x-auto no-scrollbar px-2 pt-2 pb-1": props.newLayoutDesigns,
"flex-wrap px-3 pt-3": !props.newLayoutDesigns,
}}
>
<Show when={props.newLayoutDesigns}>
<For each={props.comments ?? []}>
{(item) => (
<div class="relative group shrink-0">
<TooltipV2
value={item.comment}
placement="top"
openDelay={800}
contentClass="max-w-[300px] break-words"
>
<CommentCardV2
comment={item.comment ?? ""}
path={item.path}
selection={item.selection}
active={props.commentActive?.(item)}
onClick={() => props.onOpenComment?.(item)}
/>
</TooltipV2>
<button
type="button"
onClick={() => props.onRemoveComment?.(item)}
class={removeClassV2}
aria-label={props.removeLabel}
>
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
</button>
</div>
)}
</For>
</Show>
<For each={props.attachments}>
{(attachment) => {
const image = attachment.mime.startsWith("image/")
const media = () => (
<Show
when={attachment.mime.startsWith("image/")}
when={image}
fallback={
<div class={fallbackClass}>
<Icon name="folder" class="size-6 text-text-weak" />
</div>
<Show
when={props.newLayoutDesigns}
fallback={
<div class={fallbackClass}>
<Icon name="folder" class="size-6 text-text-weak" />
</div>
}
>
<AttachmentCardV2 title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime)}
</AttachmentCardV2>
</Show>
}
>
<img
src={attachment.dataUrl}
alt={attachment.filename}
class={imageClass}
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
onClick={() => props.onOpen(attachment)}
/>
</Show>
<button
type="button"
onClick={() => props.onRemove(attachment.id)}
class={removeClass}
aria-label={props.removeLabel}
>
<Icon name="close" class="size-3 text-text-weak" />
</button>
)
const name = () => (
<div class={nameClass}>
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
</div>
</div>
</Tooltip>
)}
</For>
)
const remove = () => (
<button
type="button"
onClick={() => props.onRemove(attachment.id)}
class={props.newLayoutDesigns ? removeClassV2 : removeClass}
aria-label={props.removeLabel}
>
<Show when={props.newLayoutDesigns} fallback={<Icon name="close" class="size-3 text-text-weak" />}>
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
</Show>
</button>
)
// v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip
return (
<Show
when={props.newLayoutDesigns}
fallback={
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
<div class="relative group">
{media()}
{name()}
{remove()}
</div>
</Tooltip>
}
>
<div class="relative group shrink-0">
<TooltipV2 value={attachment.filename} placement="top" contentClass="break-all">
{media()}
<Show when={image}>
<div class={imageHairlineClassV2} />
</Show>
</TooltipV2>
{remove()}
</div>
</Show>
)
}}
</For>
</div>
<Show when={props.newLayoutDesigns}>
<div
data-slot="prompt-attachments-fade-left"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
/>
<div
data-slot="prompt-attachments-fade-right"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
/>
</Show>
</div>
</Show>
)
@@ -1,5 +1,6 @@
import { For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path"
@@ -96,10 +97,17 @@ export function PromptWorkspaceSelector(props: {
{(branch) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<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
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>
@@ -0,0 +1,21 @@
[data-component="server-status-icon"] {
color: var(--v2-icon-icon-muted);
[data-slot="server-status-left"],
[data-slot="server-status-right"] {
fill: none;
stroke: currentColor;
}
[data-slot="server-status-dot"] {
fill: var(--server-status-color);
}
&[data-state="reconnecting"] {
--server-status-color: var(--v2-state-fg-warning);
}
&[data-state="disconnected"] {
--server-status-color: var(--v2-state-fg-danger);
}
}
@@ -0,0 +1,33 @@
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { ServerStatusIcon, type ServerStatusIconState } from "./server-status-icon"
export default {
title: "Desktop V2/Server Status Icon",
id: "desktop-v2-server-status-icon",
component: ServerStatusIcon,
tags: ["autodocs"],
}
function Preview(props: { state: ServerStatusIconState; label: string }) {
return (
<div class="flex items-center gap-3">
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
aria-label={props.label}
icon={<ServerStatusIcon state={props.state} />}
/>
<span class="text-[13px] text-v2-text-text-muted">{props.label}</span>
</div>
)
}
export const Reconnecting = {
render: () => <Preview state="reconnecting" label="Retrying automatically..." />,
}
export const Disconnected = {
render: () => <Preview state="disconnected" label="Server disconnected" />,
}
@@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test"
import { resolveServerStatus } from "./server-status-icon"
describe("resolveServerStatus", () => {
test("prioritizes a disconnected server over stream reconnection", () => {
expect(resolveServerStatus(false, "reconnecting")).toBe("disconnected")
})
test("shows reconnection while the server remains reachable", () => {
expect(resolveServerStatus(true, "reconnecting")).toBe("reconnecting")
})
test("stays hidden for healthy and initial connections", () => {
expect(resolveServerStatus(true, "connected")).toBeUndefined()
expect(resolveServerStatus(undefined, "connecting")).toBeUndefined()
})
})
@@ -0,0 +1,35 @@
import "./server-status-icon.css"
export type ServerStatusIconState = "reconnecting" | "disconnected"
export type ServerConnectionState = "connecting" | "connected" | "reconnecting"
export function resolveServerStatus(healthy: boolean | undefined, connection: ServerConnectionState) {
if (healthy === false) return "disconnected" as const
if (connection === "reconnecting") return "reconnecting" as const
}
export function ServerStatusIcon(props: { state: ServerStatusIconState }) {
return (
<svg
data-component="server-status-icon"
data-state={props.state}
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M13.3141 6.00001V14.4572H2.68555V1.54285H9.5M13.3141 14.4572V9.54285H2.68555V14.4572"
stroke="currentColor"
/>
<path data-slot="server-status-left" d="M8.5 11.75H8V12.25H8.5Z" />
<path data-slot="server-status-right" d="M11 11.75H10.5V12.25H11Z" />
<path
data-slot="server-status-dot"
d="M13 5C14.3807 5 15.5 3.88071 15.5 2.5C15.5 1.11929 14.3807 0 13 0C11.6193 0 10.5 1.11929 10.5 2.5C10.5 3.88071 11.6193 5 13 5Z"
/>
</svg>
)
}
@@ -1,6 +1,7 @@
export { SessionHeader } from "./session-header"
export { SessionContextTab } from "./session-context-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTabV2 } from "./session-sortable-tab-v2"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view"
@@ -0,0 +1,98 @@
import { For, Show } from "solid-js"
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language"
import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app"
export function OpenInAppV2(props: { directory: () => string }) {
const language = useLanguage()
const state = useOpenInApp(props)
return (
<Show when={props.directory() && state.canOpen()}>
<SplitButtonV2 class="session-review-v2-open-in-app" onPointerDown={(event) => event.stopPropagation()}>
<TooltipV2
placement="bottom"
value={language.t("session.header.open.ariaLabel", { app: state.current().label })}
class="flex items-center"
>
<SplitButtonV2Action
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
if (state.opening()) return
state.openDir(state.current().id)
}}
disabled={state.opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
>
<Show when={state.opening()} fallback={<AppIcon id={state.current().icon} class="size-[18px]" />}>
<Spinner class="size-3.5" />
</Show>
</SplitButtonV2Action>
</TooltipV2>
<MenuV2
gutter={4}
modal={false}
placement="bottom-end"
open={state.menu.open}
onOpenChange={(open) => state.setMenu("open", open)}
>
<MenuV2.Trigger
as={SplitButtonV2MenuTrigger}
disabled={state.opening()}
aria-label={language.t("session.header.open.menu")}
onPointerDown={(event) => event.stopPropagation()}
>
<IconV2 name="chevron-down" size="small" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="open-in-app-v2-menu">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.header.openIn")}</MenuV2.GroupLabel>
<MenuV2.RadioGroup
value={state.current().id}
onChange={(value) => {
state.selectApp(value as OpenApp)
}}
>
<For each={state.options()}>
{(option) => (
<MenuV2.RadioItem
value={option.id}
disabled={state.opening()}
onSelect={() => {
state.selectApp(option.id)
state.setMenu("open", false)
state.openDir(option.id)
}}
>
<AppIcon id={option.icon} />
{option.label}
</MenuV2.RadioItem>
)}
</For>
</MenuV2.RadioGroup>
</MenuV2.Group>
<MenuV2.Separator />
<MenuV2.Item
onSelect={() => {
state.setMenu("open", false)
state.copyPath()
}}
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</SplitButtonV2>
</Show>
)
}
@@ -0,0 +1,229 @@
import { createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@/utils/toast"
export const OPEN_APPS = [
"vscode",
"cursor",
"zed",
"textmate",
"antigravity",
"finder",
"terminal",
"iterm2",
"ghostty",
"warp",
"xcode",
"android-studio",
"powershell",
"sublime-text",
] as const
export type OpenApp = (typeof OPEN_APPS)[number]
export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
export const MAC_OPEN_APPS = [
{
id: "vscode",
label: "session.header.open.app.vscode",
icon: "vscode",
openWith: "Visual Studio Code",
},
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
{
id: "antigravity",
label: "session.header.open.app.antigravity",
icon: "antigravity",
openWith: "Antigravity",
},
{ id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
{ id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
{
id: "android-studio",
label: "session.header.open.app.androidStudio",
icon: "android-studio",
openWith: "Android Studio",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
export const WINDOWS_OPEN_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "powershell",
label: "session.header.open.app.powershell",
icon: "powershell",
openWith: "powershell",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
export const LINUX_OPEN_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
export function detectOpenAppOS(platform: ReturnType<typeof usePlatform>): OpenAppOS {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
const value = navigator.platform || navigator.userAgent
if (/Mac/i.test(value)) return "macos"
if (/Win/i.test(value)) return "windows"
if (/Linux/i.test(value)) return "linux"
return "unknown"
}
export function openAppFileManager(os: OpenAppOS) {
if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
return { label: "session.header.open.fileManager", icon: "finder" as const }
}
export function openAppsForOS(os: OpenAppOS) {
if (os === "macos") return MAC_OPEN_APPS
if (os === "windows") return WINDOWS_OPEN_APPS
return LINUX_OPEN_APPS
}
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
export function useOpenInApp(input: { directory: () => string }) {
const platform = usePlatform()
const server = useServer()
const language = useLanguage()
const os = createMemo(() => detectOpenAppOS(platform))
const apps = createMemo(() => openAppsForOS(os()))
const fileManager = createMemo(() => openAppFileManager(os()))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
})
createEffect(() => {
if (platform.platform !== "desktop") return
if (!platform.checkAppExists) return
const list = apps()
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
void Promise.all(
list.map((app) =>
Promise.resolve(platform.checkAppExists?.(app.openWith))
.then((value) => Boolean(value))
.catch(() => false)
.then((ok) => [app.id, ok] as const),
),
).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
})
})
const options = createMemo(() => {
return [
{ id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
...apps()
.filter((app) => exists[app.id])
.map((app) => ({ ...app, label: language.t(app.label) })),
] as const
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp | "finder" }))
const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({
app: undefined as OpenApp | undefined,
})
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(
() =>
options().find((o) => o.id === prefs.app) ??
options()[0] ??
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
)
const opening = createMemo(() => openRequest.app !== undefined)
const selectApp = (app: OpenApp | "finder") => {
if (!options().some((item) => item.id === app)) return
setPrefs("app", app)
}
const openDir = (app: OpenApp | "finder") => {
if (opening() || !canOpen() || !platform.openPath) return
const directory = input.directory()
if (!directory) return
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
setOpenRequest("app", app)
platform
.openPath(directory, openWith)
.catch((err: unknown) => showRequestError(language, err))
.finally(() => {
setOpenRequest("app", undefined)
})
}
const copyPath = () => {
const directory = input.directory()
if (!directory) return
navigator.clipboard
.writeText(directory)
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: directory,
})
})
.catch((err: unknown) => showRequestError(language, err))
}
return {
canOpen,
opening,
current,
options,
menu,
setMenu,
openDir,
selectApp,
copyPath,
}
}
@@ -1,3 +1,4 @@
// @refresh reload
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { Button } from "@opencode-ai/ui/button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
@@ -16,7 +17,9 @@ import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { ServerConnection, serverName, useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
@@ -33,6 +36,8 @@ import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
import { useTitlebarRightMount } from "../titlebar"
import { useSettingsDialog } from "../settings-dialog"
import { resolveServerStatus, ServerStatusIcon, type ServerStatusIconState } from "../server-status-icon"
const OPEN_APPS = [
"vscode",
@@ -144,6 +149,8 @@ export function SessionHeader() {
const platform = usePlatform()
const language = useLanguage()
const settings = useSettings()
const global = useGlobal()
const serverSDK = useServerSDK()
const sync = useSync()
const terminal = useTerminal()
const { params, view } = useSessionLayout()
@@ -165,6 +172,7 @@ export function SessionHeader() {
const search = settings.visibility.search
const status = settings.visibility.status
const isDesktop = createMediaQuery("(min-width: 768px)")
const openServerSettings = useSettingsDialog("servers")
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@@ -234,9 +242,25 @@ export function SessionHeader() {
const tint = createMemo(() =>
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
)
const serverStatus = createMemo<ServerStatusIconState | undefined>(() => {
const current = serverSDK()
return resolveServerStatus(
global.servers.health[ServerConnection.key(current.server)]?.healthy,
current.connection.status,
)
})
const serverStatusLabel = createMemo(() => {
if (serverStatus() === "disconnected") {
return language.t("app.server.status.disconnected", { server: serverName(serverSDK().server) })
}
return language.t("app.server.status.reconnecting", { server: serverName(serverSDK().server) })
})
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
statusVisible: status(),
statusLabel: language.t("status.popover.trigger"),
serverStatus: isDesktop() ? serverStatus() : undefined,
serverStatusLabel: serverStatusLabel(),
onServerStatusClick: openServerSettings,
toolsVisible: isDesktop() && status(),
statusLabel: language.t("status.popover.tools.trigger"),
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: reviewTooltipKeybind(command),
reviewVisible: isDesktop(),
@@ -517,7 +541,10 @@ export function SessionHeader() {
}
type SessionHeaderV2ActionsState = {
statusVisible: boolean
serverStatus?: ServerStatusIconState
serverStatusLabel: string
onServerStatusClick: () => void
toolsVisible: boolean
statusLabel: string
reviewLabel: string
reviewKeybind: string[]
@@ -530,11 +557,26 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
const language = useLanguage()
return (
<div class="flex items-center gap-2">
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<div class="flex items-center gap-[6px]">
<Show when={props.state.serverStatus}>
{(status) => (
<TooltipV2 placement="bottom" value={props.state.serverStatusLabel}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
onClick={props.state.onServerStatusClick}
aria-label={props.state.serverStatusLabel}
icon={<ServerStatusIcon state={status()} />}
/>
</TooltipV2>
)}
</Show>
<Show when={props.state.toolsVisible}>
<TooltipV2 placement="bottom" value={props.state.statusLabel}>
<StatusPopoverV2 />
</Tooltip>
</TooltipV2>
</Show>
<Show when={props.state.reviewVisible}>
<TooltipV2
@@ -0,0 +1,66 @@
import { createMemo, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useSortable } from "@dnd-kit/solid/sortable"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { FileVisual } from "./session-sortable-tab"
export function SortableTabV2(props: {
tab: string
index: () => number
temporary?: boolean
onTabClose: (tab: string) => void
onTabDoubleClick?: (tab: string) => void
}): JSX.Element {
const file = useFile()
const language = useLanguage()
const command = useCommand()
const sortable = useSortable({
get id() {
return props.tab
},
get index() {
return props.index()
},
})
const path = createMemo(() => file.pathFromTab(props.tab))
const content = createMemo(() => {
const value = path()
if (!value) return
return <FileVisual path={value} temporary={props.temporary} />
})
return (
<div ref={sortable.ref} class="h-full flex items-center">
<div class="relative">
<Tabs.Trigger
value={props.tab}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
>
<Show when={content()}>{(value) => value()}</Show>
</Tabs.Trigger>
</div>
</div>
)
}
@@ -377,8 +377,8 @@ export const SettingsGeneralV2: Component<{
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
title={language.t("settings.general.row.showTools.title")}
description={language.t("settings.general.row.showTools.description")}
>
<div data-action="settings-show-status">
<Switch
@@ -16,6 +16,7 @@ import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
import { useMcpToggle } from "@/context/mcp"
import { ToolsMenu } from "./tools-menu"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -280,7 +281,14 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const toggleMcp = useMcpToggle()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const configuredMcpNames = createMemo(() =>
Object.keys(sync().data.config.mcp ?? {}).sort((a, b) => a.localeCompare(b)),
)
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
const mcpError = (name: string) => {
const status = sync().data.mcp?.[name]
if (status?.status === "failed" || status?.status === "needs_client_registration") return status.error
}
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync().data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
@@ -289,6 +297,42 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
)
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
if (settings.general.newLayoutDesigns()) {
return (
<ToolsMenu
labels={{
menu: language.t("status.popover.tools.trigger"),
mcp: language.t("status.popover.tab.mcp"),
lsp: language.t("status.popover.tab.lsp"),
plugins: language.t("status.popover.tab.plugins"),
mcpDescription: language.t("status.popover.tools.mcp.description"),
lspDescription: language.t("status.popover.tools.lsp.description"),
pluginsDescription: language.t("status.popover.tools.plugins.description"),
disabled: language.t("mcp.status.disabled"),
failed: language.t("mcp.status.failed"),
reauthenticate: language.t("status.popover.tools.reauthenticate"),
}}
empty={{
mcp: language.t("status.popover.tools.mcp.empty"),
lsp: language.t("status.popover.tools.lsp.empty"),
plugins: language.t("status.popover.tools.plugins.empty"),
}}
mcp={configuredMcpNames().map((name) => ({
name,
status: mcpStatus(name),
error: mcpError(name),
loading: !mcpStatus(name),
pending: toggleMcp.isPending && toggleMcp.variables === name,
onToggle: () => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
},
}))}
lsp={lspItems().map((item) => ({ name: item.name || item.id, status: item.status }))}
plugins={plugins()}
/>
)
}
return (
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
@@ -0,0 +1,36 @@
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)
})
})
@@ -0,0 +1,19 @@
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"
}
+31 -52
View File
@@ -1,7 +1,7 @@
// @refresh reload
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
@@ -9,6 +9,8 @@ import { ServerConnection, useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global"
import { ToolsMenuIcon } from "./tools-menu"
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@@ -19,16 +21,14 @@ export function StatusPopover() {
const global = useGlobal()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync().data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const serverHealthy = () => global.servers.health[server.key]?.healthy === true
const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
lsp: (sync().data.lsp ?? []).map((item) => item.status),
}),
)
return (
<Popover
@@ -47,13 +47,11 @@ export function StatusPopover() {
<Icon name={shown() ? "status-active" : "status"} size="small" />
</div>
<div
classList={{
"absolute -top-px -right-px size-1.5 rounded-full": true,
"bg-icon-success-base": ready() && healthy(),
"bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
"bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
"bg-border-weak-base": serverHealthy() || !ready(),
}}
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
ready: ready(),
serverHealth: serverHealth(),
issue: issue(),
})}`}
/>
</div>
}
@@ -87,22 +85,22 @@ function DirectoryStatusPopover() {
const sync = useSync()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync().data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: Object.keys(sync().data.config.mcp ?? {}).flatMap((name) => {
const status = sync().data.mcp?.[name]
return status ? [status.status] : []
}),
lsp: (sync().data.lsp ?? []).map((item) => item.status),
}),
)
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: ready(),
healthy: healthy(),
serverHealth: serverHealth(),
issue: mcpIssue(),
label: language.t("status.popover.trigger"),
issue: issue(),
label: language.t("status.popover.tools.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
@@ -123,8 +121,8 @@ function ServerStatusPopover() {
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: serverHealth() !== undefined,
healthy: serverHealth() === true,
serverHealth: serverHealth(),
issue: false,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
@@ -140,9 +138,8 @@ function ServerStatusPopover() {
type StatusPopoverState = {
shown: boolean
ready: boolean
healthy: boolean
serverHealth: boolean | undefined
issue?: "critical" | "warning"
issue: boolean
label: string
onOpenChange: (value: boolean) => void
body: () => JSX.Element
@@ -161,16 +158,6 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
}
function StatusPopoverView(props: { state: StatusPopoverState }) {
const statusDotClass = () => ({
"absolute rounded-full": true,
"bg-icon-success-base": props.state.ready && props.state.healthy,
"bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
"bg-icon-critical-base":
props.state.serverHealth === false ||
(props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
"bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
})
const popoverProps = {
class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
@@ -191,15 +178,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
state: props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
}}
trigger={
<div class="relative size-4">
<IconV2 name={props.state.shown ? "status-active" : "status"} />
<div
classList={statusDotClass()}
class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
/>
</div>
}
trigger={<ToolsMenuIcon warning={props.state.issue} />}
{...popoverProps}
>
{props.state.body()}
@@ -0,0 +1,136 @@
import { For, type JSXElement } from "solid-js"
import { createStore } from "solid-js/store"
import { ToolsMenu, ToolsMenuIcon, type ToolsMenuProps, type ToolsMenuStatus, type ToolsMenuTab } from "./tools-menu"
export default {
title: "Desktop V2/Tools Menu",
id: "desktop-v2-tools-menu",
component: ToolsMenu,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `The desktop V2 Tools menu shown in the titlebar. Stories cover every MCP status, both LSP states, populated plugin rows, and empty states for all tabs.`,
},
},
},
}
const labels = {
menu: "Tools",
mcp: "MCP",
lsp: "LSP",
plugins: "Plugins",
mcpDescription: "Add MCP servers in opencode.json",
lspDescription: "LSPs are auto-detected as you open files",
pluginsDescription: "Add plugins in opencode.json",
disabled: "Disabled",
failed: "Failed",
reauthenticate: "Reauthenticate",
}
const empty = {
mcp: "No MCPs configured yet",
lsp: "No active language servers yet",
plugins: "No plugins configured yet",
}
const mcp = [
{ name: "ui-docs", status: "needs_auth" },
{ name: "browser-tools", status: "needs_client_registration" },
{ name: "brave-search", status: "disabled" },
{ name: "fastmail-mcp", status: "connected" },
{ name: "puppeteer", status: "failed" },
{ name: "figma", status: "connected" },
{ name: "playwright", status: "failed" },
] satisfies Array<{ name: string; status: ToolsMenuStatus }>
const lsp = [
{ name: "typescript", status: "connected" },
{ name: "eslint", status: "connected" },
{ name: "rust-analyzer", status: "error" },
] satisfies ToolsMenuProps["lsp"]
const plugins = ["opencode-devcontainers", "opencode-daytona", "~/.config/opencode/plugins/ping.ts"]
const populated = {
labels,
empty,
mcp,
lsp,
plugins,
} satisfies Omit<ToolsMenuProps, "defaultTab">
function InteractiveMenu() {
const [state, setState] = createStore({ mcp: mcp.map((item) => ({ ...item, pending: false })) })
return (
<MenuPreview warning>
<ToolsMenu
{...populated}
mcp={state.mcp.map((item, index) => ({
...item,
onToggle: () => {
if (item.status === "needs_auth" || item.status === "needs_client_registration") return
setState("mcp", index, "status", item.status === "connected" ? "disabled" : "connected")
},
}))}
/>
</MenuPreview>
)
}
function MenuPreview(props: { warning?: boolean; children: JSXElement }) {
return (
<div class="flex flex-col gap-2">
<div class="flex h-7 items-center gap-2 text-[11px] font-[530] uppercase tracking-[0.05px] text-v2-text-text-faint">
<ToolsMenuIcon warning={props.warning} />
{props.warning ? "warning" : "normal"}
</div>
{props.children}
</div>
)
}
function Gallery(props: { empty?: boolean }) {
const tabs: ToolsMenuTab[] = ["mcp", "lsp", "plugins"]
return (
<div class="flex flex-wrap items-start gap-6">
<For each={tabs}>
{(tab) => (
<MenuPreview warning={!props.empty && tab !== "plugins"}>
<ToolsMenu
{...populated}
defaultTab={tab}
mcp={props.empty ? [] : populated.mcp}
lsp={props.empty ? [] : populated.lsp}
plugins={props.empty ? [] : populated.plugins}
/>
</MenuPreview>
)}
</For>
</div>
)
}
export const Interactive = {
render: () => <InteractiveMenu />,
}
export const AllTabs = {
name: "All populated tabs",
render: () => <Gallery />,
}
export const EmptyStates = {
name: "All empty tabs",
render: () => <Gallery empty />,
}
export const McpPending = {
name: "MCP pending toggle",
render: () => (
<MenuPreview warning>
<ToolsMenu {...populated} mcp={populated.mcp.map((item) => ({ ...item, pending: item.name === "figma" }))} />
</MenuPreview>
),
}
+202
View File
@@ -0,0 +1,202 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { SegmentedControlItemV2, SegmentedControlV2 } from "@opencode-ai/ui/v2/segmented-control-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { For, Index, Show, type JSXElement } from "solid-js"
import { createStore } from "solid-js/store"
export type ToolsMenuTab = "mcp" | "lsp" | "plugins"
export type ToolsMenuStatus = "connected" | "failed" | "error" | "needs_auth" | "needs_client_registration" | "disabled"
export type ToolsMenuProps = {
defaultTab?: ToolsMenuTab
labels: {
menu: string
mcp: string
lsp: string
plugins: string
mcpDescription: string
lspDescription: string
pluginsDescription: string
disabled: string
failed: string
reauthenticate: string
}
empty: {
mcp: JSXElement
lsp: JSXElement
plugins: JSXElement
}
mcp: Array<{
name: string
status?: ToolsMenuStatus
error?: string
loading?: boolean
pending?: boolean
onToggle?: () => void
}>
lsp: Array<{
name: string
status: "connected" | "error"
}>
plugins: string[]
}
export function ToolsMenuIcon(props: { warning?: boolean }) {
return (
<div class="relative size-4 text-v2-icon-icon-muted">
<Icon name={props.warning ? "tools-warning" : "tools"} />
<Show when={props.warning}>
<div class="absolute right-0 top-0 size-[5px] rounded-full bg-icon-warning-base" />
</Show>
</div>
)
}
export function ToolsMenu(props: ToolsMenuProps) {
const [state, setState] = createStore({ tab: props.defaultTab ?? ("mcp" as ToolsMenuTab) })
return (
<div class="w-[360px] max-w-[calc(100vw-40px)] rounded-xl bg-v2-background-bg-base p-4 [font-family:var(--v2-font-family-sans)] shadow-[var(--v2-elevation-floating)]">
<SegmentedControlV2
value={state.tab}
onChange={(value) => value && setState("tab", value as ToolsMenuTab)}
class="segmented-control-v2--full-width"
aria-label={props.labels.menu}
>
<SegmentedControlItemV2 value="mcp">{props.labels.mcp}</SegmentedControlItemV2>
<SegmentedControlItemV2 value="lsp">{props.labels.lsp}</SegmentedControlItemV2>
<SegmentedControlItemV2 value="plugins">{props.labels.plugins}</SegmentedControlItemV2>
</SegmentedControlV2>
<Show when={state.tab === "mcp"}>
<Show
when={props.mcp.length > 0}
fallback={<ToolsEmpty title={props.empty.mcp} description={props.labels.mcpDescription} />}
>
<ToolsList description={props.labels.mcpDescription}>
<Index each={props.mcp}>
{(item) => (
<div class="flex h-8 items-center gap-2 px-2">
<StatusDot status={item().status} />
<span class="min-w-0 flex-1 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base">
{item().name}
</span>
<Show
when={item().status === "needs_auth"}
fallback={
<>
<Show when={item().status === "needs_client_registration"}>
<span
class="max-w-40 truncate text-[11px] font-[440] leading-4 tracking-[0.05px] text-v2-text-text-faint"
title={item().error}
>
{item().error ?? props.labels.failed}
</span>
</Show>
<Show when={item().status !== "needs_client_registration"}>
<Show when={item().status === "disabled" || item().status === "failed"}>
<span class="text-[11px] font-[440] capitalize leading-4 tracking-[0.05px] text-v2-text-text-faint">
{item().status === "disabled" ? props.labels.disabled : props.labels.failed}
</span>
</Show>
<Switch
checked={item().status === "connected"}
disabled={item().loading}
hideLabel
onChange={item().onToggle}
readOnly={item().pending}
>
{item().name}
</Switch>
</Show>
</>
}
>
<ButtonV2 size="small" variant="outline" disabled={item().pending} onClick={item().onToggle}>
{props.labels.reauthenticate}
</ButtonV2>
</Show>
</div>
)}
</Index>
</ToolsList>
</Show>
</Show>
<Show when={state.tab === "lsp"}>
<Show
when={props.lsp.length > 0}
fallback={<ToolsEmpty title={props.empty.lsp} description={props.labels.lspDescription} />}
>
<ToolsList description={props.labels.lspDescription}>
<For each={props.lsp}>
{(item) => (
<div class="flex h-8 items-center gap-2 px-2">
<StatusDot status={item.status} />
<span class="min-w-0 flex-1 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base">
{item.name}
</span>
</div>
)}
</For>
</ToolsList>
</Show>
</Show>
<Show when={state.tab === "plugins"}>
<Show
when={props.plugins.length > 0}
fallback={<ToolsEmpty title={props.empty.plugins} description={props.labels.pluginsDescription} />}
>
<ToolsList description={props.labels.pluginsDescription}>
<For each={props.plugins}>
{(plugin) => (
<div class="flex h-8 items-center gap-2 px-2">
<StatusDot status="connected" />
<span class="min-w-0 flex-1 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base">
{plugin}
</span>
</div>
)}
</For>
</ToolsList>
</Show>
</Show>
</div>
)
}
function ToolsList(props: { description: string; children: JSXElement }) {
return (
<div class="mt-2 flex flex-col">
<div class="flex h-8 items-center px-2 text-[11px] font-[440] leading-4 tracking-[0.05px] text-v2-text-text-faint">
{props.description}
</div>
{props.children}
</div>
)
}
function ToolsEmpty(props: { title: JSXElement; description: string }) {
return (
<div class="mt-4 flex flex-col gap-2 text-center text-[13px] leading-4 tracking-[-0.04px]">
<div class="h-4 font-[530] text-v2-text-text-muted">{props.title}</div>
<div class="h-4 font-[440] text-v2-text-text-faint">{props.description}</div>
</div>
)
}
function StatusDot(props: { status?: ToolsMenuStatus }) {
return (
<div
classList={{
"size-1.5 shrink-0 rounded-full": true,
"bg-v2-state-fg-success": props.status === "connected",
"bg-v2-state-fg-danger": props.status === "failed" || props.status === "error",
"bg-v2-state-fg-warning": props.status === "needs_auth" || props.status === "needs_client_registration",
"bg-v2-border-border-base": props.status === "disabled",
}}
/>
)
}
@@ -221,4 +221,56 @@ describe("createChildStoreManager", () => {
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,6 +45,8 @@ export function createChildStoreManager(input: {
const disposers = new Map<string, () => void>()
const mcpDirectories = new Set<string>()
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) => {
if (!key) return
@@ -118,6 +120,8 @@ export function createChildStoreManager(input: {
lifecycle.delete(key)
mcpDirectories.delete(key)
mcpToggles.delete(key)
activeDirectories.delete(key)
activationToggles.delete(key)
const dispose = disposers.get(key)
if (dispose) {
dispose()
@@ -182,20 +186,27 @@ export function createChildStoreManager(input: {
const initialMeta = meta[0].value
const initialIcon = icon[0].value
const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
const pathQuery = useQuery(() => input.queryOptions.path(key))
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
const providerQuery = useQuery(() => input.queryOptions.providers(key))
const referenceQuery = useQuery(() => input.queryOptions.references(key))
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key),
enabled: instanceQueriesEnabled(),
}))
const referenceQuery = useQuery(() => ({
...input.queryOptions.references(key),
enabled: instanceQueriesEnabled(),
}))
const child = createStore<State>({
project: "",
projectMeta: initialMeta,
icon: initialIcon,
get provider_ready() {
return !providerQuery.isLoading
return instanceQueriesEnabled() && !providerQuery.isLoading
},
get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} }
@@ -236,7 +247,7 @@ export function createChildStoreManager(input: {
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
},
get lsp_ready() {
return !lspQuery.isLoading
return instanceQueriesEnabled() && !lspQuery.isLoading
},
get lsp() {
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
@@ -250,6 +261,7 @@ export function createChildStoreManager(input: {
children[key] = child
disposers.set(key, dispose)
mcpToggles.set(key, setMcpEnabled)
activationToggles.set(key, setInstanceQueriesEnabled)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
@@ -290,6 +302,7 @@ export function createChildStoreManager(input: {
pinForOwner(key)
if (options.mcp) enableMcp(directory, key, childStore)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap) activate(key)
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
@@ -301,6 +314,7 @@ export function createChildStoreManager(input: {
const childStore = ensureChild(directory)
if (options.mcp) enableMcp(directory, key, childStore)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap) activate(key)
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
@@ -314,6 +328,16 @@ export function createChildStoreManager(input: {
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) {
const key = directoryKey(directory)
if (!mcpDirectories.delete(key)) return
@@ -360,6 +384,7 @@ export function createChildStoreManager(input: {
unpin,
pinned,
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
disableMcp,
disposeDirectory,
runEviction,
@@ -243,6 +243,22 @@ describe("applyDirectoryEvent", () => {
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", () => {
const cases = [
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
@@ -146,15 +146,14 @@ export function applyDirectoryEvent(input: {
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (info.time.archived) {
if (!result.found) break
if (input.store.session[result.index]!.time.archived === info.time.archived) break
if (result.found) {
input.setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
input.setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
@@ -0,0 +1,144 @@
import { describe, expect, test } from "bun:test"
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 result = parseHomeSessionIndex([
session({ id: "root", updated: 30 }),
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 },
}),
])
})
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)
})
})
@@ -0,0 +1,174 @@
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 || item.time.archived !== undefined) 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 || info.time.archived !== undefined) {
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,
}
}
+12 -4
View File
@@ -47,12 +47,20 @@ export function getAvatarColors(key?: string) {
}
export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
if (key === "orange") return "orange"
if (key === "pink") return "pink"
if (key === "cyan") return "cyan"
if (key === "purple") return "purple"
if (key === "mint") return "cyan"
if (key === "lime") return "green"
if (
key === "orange" ||
key === "yellow" ||
key === "cyan" ||
key === "green" ||
key === "red" ||
key === "pink" ||
key === "blue" ||
key === "purple" ||
key === "gray"
)
return key
return "gray"
}
+3
View File
@@ -37,6 +37,9 @@ type PlatformBase = {
/** Open a local path in a local app (desktop only) */
openPath?(path: string, app?: string): Promise<void>
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
revealPath?(path: string): Promise<boolean>
/** Restart the app */
restart(): Promise<void>
+1 -1
View File
@@ -145,7 +145,7 @@ function contextItemKey(item: ContextItem) {
return `${key}:c=${digest.slice(0, 8)}`
}
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
+1
View File
@@ -24,6 +24,7 @@ export {
createPromptSession,
createPromptState,
DEFAULT_PROMPT,
isCommentItem,
isPromptEqual,
} from "./prompt-state"
export type {
+7
View File
@@ -3,6 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
@@ -142,6 +143,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
let run: Promise<void> | undefined
let started = false
let generation = 0
const [connection, setConnection] = createStore({
status: "connecting" as "connecting" | "connected" | "reconnecting",
})
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
@@ -187,6 +191,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
})
},
})
setConnection("status", "connected")
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
@@ -218,6 +223,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
if (abort.signal.aborted || !started || generation !== active) return
setConnection("status", "reconnecting")
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
@@ -269,6 +275,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
listen: emitter.listen.bind(emitter),
start,
},
connection,
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
return createSdkForServer({
server: server.http,
+13 -1
View File
@@ -43,6 +43,7 @@ import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { retry } from "@opencode-ai/core/util/retry"
import type { ServerScope } from "@/utils/server-scope"
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
import { persisted } from "@/utils/persist"
import { toggleMcp } from "./global-sync/mcp"
import { createServerSession } from "./server-session"
@@ -153,6 +154,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
})
const queryClient = useQueryClient()
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
let bootedAt = 0
let bootingRoot = false
@@ -376,6 +378,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const recent = bootingRoot || Date.now() - bootedAt < 1500
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") {
applyGlobalEvent({
@@ -390,6 +396,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (event.type === "server.connected" || event.type === "global.disposed") {
if (recent) return
for (const directory of Object.keys(children.children)) {
if (!children.active(directory)) continue
queue.push(directory)
}
}
@@ -405,15 +412,19 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
directory,
store,
setStore,
push: queue.push,
push: (directory) => {
if (children.active(directory)) queue.push(directory)
},
retainedLimit: sessionMeta.get(key)?.limit,
sessionContent: false,
permission: session.data.permission,
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
loadReferences: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.references(key))
},
})
@@ -486,6 +497,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
updateConfig: updateConfigMutation.mutateAsync,
project: projectApi,
session,
homeSessions,
mcp: {
toggle: async (directory: string, name: string) => {
const key = directoryKey(directory)
+12
View File
@@ -621,6 +621,14 @@ export const dict = {
"session.header.open.menu": "خيارات الفتح",
"session.header.open.copyPath": "نسخ المسار",
"status.popover.trigger": "الحالة",
"status.popover.tools.trigger": "الأدوات",
"status.popover.tools.mcp.description": "أضف خوادم MCP في opencode.json",
"status.popover.tools.lsp.description": "يتم اكتشاف خوادم LSP تلقائيًا عند فتح الملفات",
"status.popover.tools.plugins.description": "أضف الإضافات في opencode.json",
"status.popover.tools.mcp.empty": "لم يتم إعداد أي خوادم MCP بعد",
"status.popover.tools.lsp.empty": "لا توجد خوادم لغة نشطة بعد",
"status.popover.tools.plugins.empty": "لم يتم إعداد أي إضافات بعد",
"status.popover.tools.reauthenticate": "إعادة المصادقة",
"status.popover.ariaLabel": "إعدادات الخوادم",
"status.popover.tab.servers": "الخوادم",
"status.popover.tab.mcp": "MCP",
@@ -722,6 +730,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "إظهار زر المحطة الطرفية في شريط عنوان سطح المكتب",
"settings.general.row.showStatus.title": "حالة الخادم",
"settings.general.row.showStatus.description": "إظهار زر حالة الخادم في شريط العنوان",
"settings.general.row.showTools.title": "قائمة الأدوات",
"settings.general.row.showTools.description": "إظهار وصول سريع إلى MCP وLSP والإضافات في شريط العنوان",
"settings.general.row.mobileTitlebarBottom.title": "التنقل السفلي",
"settings.general.row.mobileTitlebarBottom.description":
"وضع شريط العنوان وعلامات تبويب الجلسات أسفل الشاشة على الأجهزة المحمولة",
@@ -929,6 +939,8 @@ export const dict = {
"app.server.unreachable": "تعذر الوصول إلى {{server}}",
"app.server.retrying": "جاري إعادة المحاولة تلقائيًا...",
"app.server.status.reconnecting": "جارٍ إعادة الاتصال بـ {{server}}...",
"app.server.status.disconnected": "تعذر الاتصال بـ {{server}}",
"app.server.otherServers": "خوادم أخرى",
"dialog.server.add.usernamePlaceholder": "اسم المستخدم",
"dialog.server.add.passwordPlaceholder": "كلمة المرور",
+12
View File
@@ -627,6 +627,14 @@ export const dict = {
"session.header.open.menu": "Opções de abertura",
"session.header.open.copyPath": "Copiar caminho",
"status.popover.trigger": "Status",
"status.popover.tools.trigger": "Ferramentas",
"status.popover.tools.mcp.description": "Adicione servidores MCP no opencode.json",
"status.popover.tools.lsp.description": "Os LSPs são detectados automaticamente ao abrir arquivos",
"status.popover.tools.plugins.description": "Adicione plugins no opencode.json",
"status.popover.tools.mcp.empty": "Nenhum MCP configurado ainda",
"status.popover.tools.lsp.empty": "Nenhum servidor de linguagem ativo ainda",
"status.popover.tools.plugins.empty": "Nenhum plugin configurado ainda",
"status.popover.tools.reauthenticate": "Autenticar novamente",
"status.popover.ariaLabel": "Configurações de servidores",
"status.popover.tab.servers": "Servidores",
"status.popover.tab.mcp": "MCP",
@@ -733,6 +741,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Mostrar o botão do terminal na barra de título do desktop",
"settings.general.row.showStatus.title": "Status do servidor",
"settings.general.row.showStatus.description": "Mostrar o botão de status do servidor na barra de título",
"settings.general.row.showTools.title": "Menu de ferramentas",
"settings.general.row.showTools.description": "Mostrar acesso rápido a MCPs, LSPs e plugins na barra de título",
"settings.general.row.mobileTitlebarBottom.title": "Navegação inferior",
"settings.general.row.mobileTitlebarBottom.description":
"Posicionar a barra de título e as abas da sessão na parte inferior da tela em dispositivos móveis",
@@ -943,6 +953,8 @@ export const dict = {
"app.server.unreachable": "Não foi possível conectar a {{server}}",
"app.server.retrying": "Tentando novamente automaticamente...",
"app.server.status.reconnecting": "Reconectando a {{server}}...",
"app.server.status.disconnected": "Não foi possível conectar a {{server}}",
"app.server.otherServers": "Outros servidores",
"dialog.server.add.usernamePlaceholder": "nome de usuário",
"dialog.server.add.passwordPlaceholder": "senha",
+12
View File
@@ -685,6 +685,14 @@ export const dict = {
"session.header.open.copyPath": "Kopiraj putanju",
"status.popover.trigger": "Status",
"status.popover.tools.trigger": "Alati",
"status.popover.tools.mcp.description": "Dodajte MCP servere u opencode.json",
"status.popover.tools.lsp.description": "LSP serveri se automatski otkrivaju dok otvarate datoteke",
"status.popover.tools.plugins.description": "Dodajte dodatke u opencode.json",
"status.popover.tools.mcp.empty": "Još nema konfigurisanih MCP servera",
"status.popover.tools.lsp.empty": "Još nema aktivnih jezičkih servera",
"status.popover.tools.plugins.empty": "Još nema konfigurisanih dodataka",
"status.popover.tools.reauthenticate": "Ponovo se autentificiraj",
"status.popover.ariaLabel": "Konfiguracije servera",
"status.popover.tab.servers": "Serveri",
"status.popover.tab.mcp": "MCP",
@@ -797,6 +805,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Prikaži dugme terminala u naslovnoj traci desktop aplikacije",
"settings.general.row.showStatus.title": "Status servera",
"settings.general.row.showStatus.description": "Prikaži dugme statusa servera u naslovnoj traci",
"settings.general.row.showTools.title": "Meni alata",
"settings.general.row.showTools.description": "Prikaži brzi pristup MCP, LSP i dodacima u naslovnoj traci",
"settings.general.row.mobileTitlebarBottom.title": "Donja navigacija",
"settings.general.row.mobileTitlebarBottom.description":
"Postavi naslovnu traku i kartice sesije na dno ekrana na mobilnim uređajima",
@@ -1019,6 +1029,8 @@ export const dict = {
"app.server.unreachable": "Nije moguće pristupiti {{server}}",
"app.server.retrying": "Automatski ponovni pokušaj...",
"app.server.status.reconnecting": "Ponovno povezivanje sa {{server}}...",
"app.server.status.disconnected": "Nije moguće povezati se sa {{server}}",
"app.server.otherServers": "Drugi serveri",
"dialog.server.add.usernamePlaceholder": "korisničko ime",
"dialog.server.add.passwordPlaceholder": "lozinka",
+12
View File
@@ -680,6 +680,14 @@ export const dict = {
"session.header.open.copyPath": "Kopier sti",
"status.popover.trigger": "Status",
"status.popover.tools.trigger": "Værktøjer",
"status.popover.tools.mcp.description": "Tilføj MCP-servere i opencode.json",
"status.popover.tools.lsp.description": "LSP'er registreres automatisk, når du åbner filer",
"status.popover.tools.plugins.description": "Tilføj plugins i opencode.json",
"status.popover.tools.mcp.empty": "Ingen MCP'er konfigureret endnu",
"status.popover.tools.lsp.empty": "Ingen aktive sprogservere endnu",
"status.popover.tools.plugins.empty": "Ingen plugins konfigureret endnu",
"status.popover.tools.reauthenticate": "Godkend igen",
"status.popover.ariaLabel": "Serverkonfigurationer",
"status.popover.tab.servers": "Servere",
"status.popover.tab.mcp": "MCP",
@@ -791,6 +799,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Vis terminalknappen i desktopappens titellinje",
"settings.general.row.showStatus.title": "Serverstatus",
"settings.general.row.showStatus.description": "Vis serverstatusknappen i titellinjen",
"settings.general.row.showTools.title": "Værktøjsmenu",
"settings.general.row.showTools.description": "Vis hurtig adgang til MCP'er, LSP'er og plugins i titellinjen",
"settings.general.row.mobileTitlebarBottom.title": "Navigation nederst",
"settings.general.row.mobileTitlebarBottom.description":
"Placer titellinjen og sessionsfanerne nederst på skærmen på mobilenheder",
@@ -1011,6 +1021,8 @@ export const dict = {
"app.server.unreachable": "Kunne ikke nå {{server}}",
"app.server.retrying": "Prøver igen automatisk...",
"app.server.status.reconnecting": "Opretter forbindelse til {{server}} igen...",
"app.server.status.disconnected": "Kunne ikke oprette forbindelse til {{server}}",
"app.server.otherServers": "Andre servere",
"dialog.server.add.usernamePlaceholder": "brugernavn",
"dialog.server.add.passwordPlaceholder": "adgangskode",
+12
View File
@@ -636,6 +636,14 @@ export const dict = {
"session.header.open.menu": "Öffnen-Optionen",
"session.header.open.copyPath": "Pfad kopieren",
"status.popover.trigger": "Status",
"status.popover.tools.trigger": "Werkzeuge",
"status.popover.tools.mcp.description": "MCP-Server in opencode.json hinzufügen",
"status.popover.tools.lsp.description": "LSPs werden beim Öffnen von Dateien automatisch erkannt",
"status.popover.tools.plugins.description": "Plugins in opencode.json hinzufügen",
"status.popover.tools.mcp.empty": "Noch keine MCPs konfiguriert",
"status.popover.tools.lsp.empty": "Noch keine aktiven Sprachserver",
"status.popover.tools.plugins.empty": "Noch keine Plugins konfiguriert",
"status.popover.tools.reauthenticate": "Erneut authentifizieren",
"status.popover.ariaLabel": "Serverkonfigurationen",
"status.popover.tab.servers": "Server",
"status.popover.tab.mcp": "MCP",
@@ -745,6 +753,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Terminal-Schaltfläche in der Desktop-Titelleiste anzeigen",
"settings.general.row.showStatus.title": "Serverstatus",
"settings.general.row.showStatus.description": "Serverstatus-Schaltfläche in der Titelleiste anzeigen",
"settings.general.row.showTools.title": "Werkzeugmenü",
"settings.general.row.showTools.description": "Schnellzugriff auf MCPs, LSPs und Plugins in der Titelleiste anzeigen",
"settings.general.row.mobileTitlebarBottom.title": "Navigation unten",
"settings.general.row.mobileTitlebarBottom.description":
"Titelleiste und Sitzungs-Tabs auf Mobilgeräten am unteren Bildschirmrand platzieren",
@@ -955,6 +965,8 @@ export const dict = {
"app.server.unreachable": "Konnte {{server}} nicht erreichen",
"app.server.retrying": "Automatische erneute Verbindung...",
"app.server.status.reconnecting": "Verbindung zu {{server}} wird wiederhergestellt...",
"app.server.status.disconnected": "Verbindung zu {{server}} konnte nicht hergestellt werden",
"app.server.otherServers": "Andere Server",
"dialog.server.add.usernamePlaceholder": "Benutzername",
"dialog.server.add.passwordPlaceholder": "Passwort",
+13 -1
View File
@@ -332,6 +332,8 @@ export const dict = {
"app.server.unreachable": "Could not reach {{server}}",
"app.server.retrying": "Retrying automatically...",
"app.server.status.reconnecting": "Reconnecting to {{server}}...",
"app.server.status.disconnected": "Couldn't connect to {{server}}",
"app.server.otherServers": "Other servers",
"dialog.server.title": "Servers",
@@ -638,7 +640,7 @@ export const dict = {
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab",
"session.error.serverConnection": "Can't connect to this server",
"session.review.filesChanged": "{{count}} Files Changed",
"session.review.filesChanged": "Files Changed {{count}}",
"session.review.change.one": "Change",
"session.review.change.other": "Changes",
"session.review.loadingChanges": "Loading changes...",
@@ -728,6 +730,14 @@ export const dict = {
"status.popover.trigger": "Status",
"status.popover.ariaLabel": "Server configurations",
"status.popover.tools.trigger": "Tools",
"status.popover.tools.mcp.description": "Add MCP servers in opencode.json",
"status.popover.tools.lsp.description": "LSPs are auto-detected as you open files",
"status.popover.tools.plugins.description": "Add plugins in opencode.json",
"status.popover.tools.mcp.empty": "No MCPs configured yet",
"status.popover.tools.lsp.empty": "No active language servers yet",
"status.popover.tools.plugins.empty": "No plugins configured yet",
"status.popover.tools.reauthenticate": "Reauthenticate",
"status.popover.tab.servers": "Servers",
"status.popover.tab.mcp": "MCP",
"status.popover.tab.lsp": "LSP",
@@ -886,6 +896,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
"settings.general.row.showTools.title": "Tools menu",
"settings.general.row.showTools.description": "Show quick access to MCPs, LSPs, and plugins in the title bar",
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
"settings.general.row.mobileTitlebarBottom.description":
"Place the title bar and session tabs at the bottom of the screen on mobile",
+12
View File
@@ -686,6 +686,14 @@ export const dict = {
"session.header.open.copyPath": "Copiar ruta",
"status.popover.trigger": "Estado",
"status.popover.tools.trigger": "Herramientas",
"status.popover.tools.mcp.description": "Añade servidores MCP en opencode.json",
"status.popover.tools.lsp.description": "Los LSP se detectan automáticamente al abrir archivos",
"status.popover.tools.plugins.description": "Añade plugins en opencode.json",
"status.popover.tools.mcp.empty": "Aún no hay MCP configurados",
"status.popover.tools.lsp.empty": "Aún no hay servidores de lenguaje activos",
"status.popover.tools.plugins.empty": "Aún no hay plugins configurados",
"status.popover.tools.reauthenticate": "Volver a autenticar",
"status.popover.ariaLabel": "Configuraciones del servidor",
"status.popover.tab.servers": "Servidores",
"status.popover.tab.mcp": "MCP",
@@ -802,6 +810,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Mostrar el botón del terminal en la barra de título",
"settings.general.row.showStatus.title": "Estado del servidor",
"settings.general.row.showStatus.description": "Mostrar el botón de estado del servidor en la barra de título",
"settings.general.row.showTools.title": "Menú de herramientas",
"settings.general.row.showTools.description": "Mostrar acceso rápido a MCP, LSP y plugins en la barra de título",
"settings.general.row.mobileTitlebarBottom.title": "Navegación inferior",
"settings.general.row.mobileTitlebarBottom.description":
"Colocar la barra de título y las pestañas de sesión en la parte inferior de la pantalla en dispositivos móviles",
@@ -1027,6 +1037,8 @@ export const dict = {
"app.server.unreachable": "No se pudo conectar con {{server}}",
"app.server.retrying": "Reintentando automáticamente...",
"app.server.status.reconnecting": "Reconectando con {{server}}...",
"app.server.status.disconnected": "No se pudo conectar con {{server}}",
"app.server.otherServers": "Otros servidores",
"dialog.server.add.usernamePlaceholder": "usuario",
"dialog.server.add.passwordPlaceholder": "contraseña",
+13
View File
@@ -632,6 +632,14 @@ export const dict = {
"session.header.open.menu": "Options d'ouverture",
"session.header.open.copyPath": "Copier le chemin",
"status.popover.trigger": "Statut",
"status.popover.tools.trigger": "Outils",
"status.popover.tools.mcp.description": "Ajouter des serveurs MCP dans opencode.json",
"status.popover.tools.lsp.description": "Les LSP sont détectés automatiquement à louverture des fichiers",
"status.popover.tools.plugins.description": "Ajouter des plugins dans opencode.json",
"status.popover.tools.mcp.empty": "Aucun MCP configuré pour le moment",
"status.popover.tools.lsp.empty": "Aucun serveur de langage actif pour le moment",
"status.popover.tools.plugins.empty": "Aucun plugin configuré pour le moment",
"status.popover.tools.reauthenticate": "Sauthentifier à nouveau",
"status.popover.ariaLabel": "Configurations des serveurs",
"status.popover.tab.servers": "Serveurs",
"status.popover.tab.mcp": "MCP",
@@ -742,6 +750,9 @@ export const dict = {
"Afficher le bouton du terminal dans la barre de titre de l'application desktop",
"settings.general.row.showStatus.title": "État du serveur",
"settings.general.row.showStatus.description": "Afficher le bouton d'état du serveur dans la barre de titre",
"settings.general.row.showTools.title": "Menu Outils",
"settings.general.row.showTools.description":
"Afficher un accès rapide aux MCP, LSP et plugins dans la barre de titre",
"settings.general.row.mobileTitlebarBottom.title": "Navigation en bas",
"settings.general.row.mobileTitlebarBottom.description":
"Placer la barre de titre et les onglets de session en bas de l'écran sur mobile",
@@ -954,6 +965,8 @@ export const dict = {
"app.server.unreachable": "Impossible de joindre {{server}}",
"app.server.retrying": "Nouvelle tentative automatique...",
"app.server.status.reconnecting": "Reconnexion à {{server}}...",
"app.server.status.disconnected": "Impossible de se connecter à {{server}}",
"app.server.otherServers": "Autres serveurs",
"dialog.server.add.usernamePlaceholder": "nom d'utilisateur",
"dialog.server.add.passwordPlaceholder": "mot de passe",
+12
View File
@@ -623,6 +623,14 @@ export const dict = {
"session.header.open.menu": "開くオプション",
"session.header.open.copyPath": "パスをコピー",
"status.popover.trigger": "ステータス",
"status.popover.tools.trigger": "ツール",
"status.popover.tools.mcp.description": "opencode.json に MCP サーバーを追加",
"status.popover.tools.lsp.description": "ファイルを開くと LSP が自動検出されます",
"status.popover.tools.plugins.description": "opencode.json にプラグインを追加",
"status.popover.tools.mcp.empty": "MCP はまだ設定されていません",
"status.popover.tools.lsp.empty": "アクティブな言語サーバーはまだありません",
"status.popover.tools.plugins.empty": "プラグインはまだ設定されていません",
"status.popover.tools.reauthenticate": "再認証",
"status.popover.ariaLabel": "サーバー設定",
"status.popover.tab.servers": "サーバー",
"status.popover.tab.mcp": "MCP",
@@ -728,6 +736,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "デスクトップのタイトルバーにターミナルボタンを表示します",
"settings.general.row.showStatus.title": "サーバーステータス",
"settings.general.row.showStatus.description": "タイトルバーにサーバーステータスボタンを表示します",
"settings.general.row.showTools.title": "ツールメニュー",
"settings.general.row.showTools.description": "タイトルバーに MCP、LSP、プラグインへのクイックアクセスを表示します",
"settings.general.row.mobileTitlebarBottom.title": "下部ナビゲーション",
"settings.general.row.mobileTitlebarBottom.description":
"モバイルではタイトルバーとセッションタブを画面下部に配置します",
@@ -936,6 +946,8 @@ export const dict = {
"app.server.unreachable": "{{server}} に到達できませんでした",
"app.server.retrying": "自動的に再試行中...",
"app.server.status.reconnecting": "{{server}} に再接続しています...",
"app.server.status.disconnected": "{{server}} に接続できませんでした",
"app.server.otherServers": "その他のサーバー",
"dialog.server.add.usernamePlaceholder": "ユーザー名",
"dialog.server.add.passwordPlaceholder": "パスワード",
+12
View File
@@ -503,6 +503,14 @@ export const dict = {
"session.header.open.menu": "열기 옵션",
"session.header.open.copyPath": "경로 복사",
"status.popover.trigger": "상태",
"status.popover.tools.trigger": "도구",
"status.popover.tools.mcp.description": "opencode.json에 MCP 서버 추가",
"status.popover.tools.lsp.description": "파일을 열면 LSP가 자동으로 감지됩니다",
"status.popover.tools.plugins.description": "opencode.json에 플러그인 추가",
"status.popover.tools.mcp.empty": "아직 구성된 MCP가 없습니다",
"status.popover.tools.lsp.empty": "아직 활성 언어 서버가 없습니다",
"status.popover.tools.plugins.empty": "아직 구성된 플러그인이 없습니다",
"status.popover.tools.reauthenticate": "다시 인증",
"status.popover.ariaLabel": "서버 구성",
"status.popover.tab.servers": "서버",
"status.popover.tab.mcp": "MCP",
@@ -782,6 +790,8 @@ export const dict = {
"app.server.unreachable": "{{server}}에 연결할 수 없습니다",
"app.server.retrying": "자동으로 재시도 중...",
"app.server.status.reconnecting": "{{server}}에 다시 연결하는 중...",
"app.server.status.disconnected": "{{server}}에 연결할 수 없습니다",
"app.server.otherServers": "다른 서버",
"dialog.server.add.usernamePlaceholder": "사용자 이름",
"dialog.server.add.passwordPlaceholder": "비밀번호",
@@ -995,6 +1005,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "데스크톱 제목 표시줄에 터미널 버튼 표시",
"settings.general.row.showStatus.title": "서버 상태",
"settings.general.row.showStatus.description": "제목 표시줄에 서버 상태 버튼 표시",
"settings.general.row.showTools.title": "도구 메뉴",
"settings.general.row.showTools.description": "제목 표시줄에 MCP, LSP 및 플러그인 빠른 접근 표시",
"settings.general.row.mobileTitlebarBottom.title": "하단 탐색",
"settings.general.row.mobileTitlebarBottom.description": "모바일에서 제목 표시줄과 세션 탭을 화면 하단에 배치",
"settings.general.row.showCustomAgents.title": "사용자 지정 에이전트",
+12
View File
@@ -567,6 +567,14 @@ export const dict = {
"session.header.open.copyPath": "Kopier bane",
"status.popover.trigger": "Status",
"status.popover.tools.trigger": "Verktøy",
"status.popover.tools.mcp.description": "Legg til MCP-servere i opencode.json",
"status.popover.tools.lsp.description": "LSP-er oppdages automatisk når du åpner filer",
"status.popover.tools.plugins.description": "Legg til plugins i opencode.json",
"status.popover.tools.mcp.empty": "Ingen MCP-er konfigurert ennå",
"status.popover.tools.lsp.empty": "Ingen aktive språkservere ennå",
"status.popover.tools.plugins.empty": "Ingen plugins konfigurert ennå",
"status.popover.tools.reauthenticate": "Autentiser på nytt",
"status.popover.ariaLabel": "Serverkonfigurasjoner",
"status.popover.tab.servers": "Servere",
"status.popover.tab.mcp": "MCP",
@@ -869,6 +877,8 @@ export const dict = {
"app.server.unreachable": "Kunne ikke nå {{server}}",
"app.server.retrying": "Prøver på nytt automatisk...",
"app.server.status.reconnecting": "Kobler til {{server}} på nytt...",
"app.server.status.disconnected": "Kunne ikke koble til {{server}}",
"app.server.otherServers": "Andre servere",
"dialog.server.add.usernamePlaceholder": "brukernavn",
"dialog.server.add.passwordPlaceholder": "passord",
@@ -1090,6 +1100,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Vis terminalknappen på tittellinjen i skrivebordsappen",
"settings.general.row.showStatus.title": "Serverstatus",
"settings.general.row.showStatus.description": "Vis serverstatusknappen på tittellinjen",
"settings.general.row.showTools.title": "Verktøymeny",
"settings.general.row.showTools.description": "Vis hurtigtilgang til MCP-er, LSP-er og plugins på tittellinjen",
"settings.general.row.mobileTitlebarBottom.title": "Navigasjon nederst",
"settings.general.row.mobileTitlebarBottom.description":
"Plasser tittellinjen og sesjonsfanene nederst på mobilskjermen",
+12
View File
@@ -627,6 +627,14 @@ export const dict = {
"session.header.open.menu": "Opcje otwierania",
"session.header.open.copyPath": "Kopiuj ścieżkę",
"status.popover.trigger": "Status",
"status.popover.tools.trigger": "Narzędzia",
"status.popover.tools.mcp.description": "Dodaj serwery MCP w opencode.json",
"status.popover.tools.lsp.description": "LSP są wykrywane automatycznie podczas otwierania plików",
"status.popover.tools.plugins.description": "Dodaj wtyczki w opencode.json",
"status.popover.tools.mcp.empty": "Nie skonfigurowano jeszcze MCP",
"status.popover.tools.lsp.empty": "Brak aktywnych serwerów językowych",
"status.popover.tools.plugins.empty": "Nie skonfigurowano jeszcze wtyczek",
"status.popover.tools.reauthenticate": "Uwierzytelnij ponownie",
"status.popover.ariaLabel": "Konfiguracje serwerów",
"status.popover.tab.servers": "Serwery",
"status.popover.tab.mcp": "MCP",
@@ -733,6 +741,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Pokaż przycisk terminala na pasku tytułu aplikacji komputerowej",
"settings.general.row.showStatus.title": "Status serwera",
"settings.general.row.showStatus.description": "Pokaż przycisk statusu serwera na pasku tytułu",
"settings.general.row.showTools.title": "Menu narzędzi",
"settings.general.row.showTools.description": "Pokaż szybki dostęp do MCP, LSP i wtyczek na pasku tytułu",
"settings.general.row.mobileTitlebarBottom.title": "Dolna nawigacja",
"settings.general.row.mobileTitlebarBottom.description":
"Umieść pasek tytułu i karty sesji u dołu ekranu na urządzeniach mobilnych",
@@ -942,6 +952,8 @@ export const dict = {
"app.server.unreachable": "Nie można połączyć z {{server}}",
"app.server.retrying": "Ponawianie automatycznie...",
"app.server.status.reconnecting": "Ponowne łączenie z {{server}}...",
"app.server.status.disconnected": "Nie można połączyć z {{server}}",
"app.server.otherServers": "Inne serwery",
"dialog.server.add.usernamePlaceholder": "nazwa użytkownika",
"dialog.server.add.passwordPlaceholder": "hasło",
+12
View File
@@ -683,6 +683,14 @@ export const dict = {
"session.header.open.copyPath": "Копировать путь",
"status.popover.trigger": "Статус",
"status.popover.tools.trigger": "Инструменты",
"status.popover.tools.mcp.description": "Добавьте серверы MCP в opencode.json",
"status.popover.tools.lsp.description": "LSP определяются автоматически при открытии файлов",
"status.popover.tools.plugins.description": "Добавьте плагины в opencode.json",
"status.popover.tools.mcp.empty": "MCP пока не настроены",
"status.popover.tools.lsp.empty": "Активных языковых серверов пока нет",
"status.popover.tools.plugins.empty": "Плагины пока не настроены",
"status.popover.tools.reauthenticate": "Повторно авторизоваться",
"status.popover.ariaLabel": "Настройки серверов",
"status.popover.tab.servers": "Серверы",
"status.popover.tab.mcp": "MCP",
@@ -798,6 +806,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Показывать кнопку терминала в строке заголовка приложения",
"settings.general.row.showStatus.title": "Состояние сервера",
"settings.general.row.showStatus.description": "Показывать кнопку состояния сервера в строке заголовка",
"settings.general.row.showTools.title": "Меню инструментов",
"settings.general.row.showTools.description": "Показывать быстрый доступ к MCP, LSP и плагинам в строке заголовка",
"settings.general.row.mobileTitlebarBottom.title": "Нижняя навигация",
"settings.general.row.mobileTitlebarBottom.description":
"На мобильных устройствах размещать строку заголовка и вкладки сессии внизу экрана",
@@ -1022,6 +1032,8 @@ export const dict = {
"app.server.unreachable": "Не удалось связаться с {{server}}",
"app.server.retrying": "Автоматическая повторная попытка...",
"app.server.status.reconnecting": "Повторное подключение к {{server}}...",
"app.server.status.disconnected": "Не удалось подключиться к {{server}}",
"app.server.otherServers": "Другие серверы",
"dialog.server.add.usernamePlaceholder": "имя пользователя",
"dialog.server.add.passwordPlaceholder": "пароль",
+12
View File
@@ -679,6 +679,14 @@ export const dict = {
"session.header.open.copyPath": "คัดลอกเส้นทาง",
"status.popover.trigger": "สถานะ",
"status.popover.tools.trigger": "เครื่องมือ",
"status.popover.tools.mcp.description": "เพิ่มเซิร์ฟเวอร์ MCP ใน opencode.json",
"status.popover.tools.lsp.description": "ระบบจะตรวจพบ LSP อัตโนมัติเมื่อคุณเปิดไฟล์",
"status.popover.tools.plugins.description": "เพิ่มปลั๊กอินใน opencode.json",
"status.popover.tools.mcp.empty": "ยังไม่ได้กำหนดค่า MCP",
"status.popover.tools.lsp.empty": "ยังไม่มีเซิร์ฟเวอร์ภาษาที่ใช้งานอยู่",
"status.popover.tools.plugins.empty": "ยังไม่ได้กำหนดค่าปลั๊กอิน",
"status.popover.tools.reauthenticate": "ยืนยันตัวตนอีกครั้ง",
"status.popover.ariaLabel": "การกำหนดค่าเซิร์ฟเวอร์",
"status.popover.tab.servers": "เซิร์ฟเวอร์",
"status.popover.tab.mcp": "MCP",
@@ -789,6 +797,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "แสดงปุ่มเทอร์มินัลในแถบชื่อเรื่องเดสก์ท็อป",
"settings.general.row.showStatus.title": "สถานะเซิร์ฟเวอร์",
"settings.general.row.showStatus.description": "แสดงปุ่มสถานะเซิร์ฟเวอร์ในแถบชื่อเรื่อง",
"settings.general.row.showTools.title": "เมนูเครื่องมือ",
"settings.general.row.showTools.description": "แสดงทางลัดไปยัง MCP, LSP และปลั๊กอินในแถบชื่อเรื่อง",
"settings.general.row.mobileTitlebarBottom.title": "การนำทางด้านล่าง",
"settings.general.row.mobileTitlebarBottom.description":
"วางแถบชื่อเรื่องและแท็บเซสชันไว้ด้านล่างของหน้าจอบนอุปกรณ์เคลื่อนที่",
@@ -1006,6 +1016,8 @@ export const dict = {
"app.server.unreachable": "ไม่สามารถติดต่อ {{server}}",
"app.server.retrying": "กำลังลองใหม่โดยอัตโนมัติ...",
"app.server.status.reconnecting": "กำลังเชื่อมต่อกับ {{server}} อีกครั้ง...",
"app.server.status.disconnected": "ไม่สามารถเชื่อมต่อกับ {{server}}",
"app.server.otherServers": "เซิร์ฟเวอร์อื่น ๆ",
"dialog.server.add.usernamePlaceholder": "ชื่อผู้ใช้",
"dialog.server.add.passwordPlaceholder": "รหัสผ่าน",
+12
View File
@@ -689,6 +689,14 @@ export const dict = {
"session.header.open.copyPath": "Yolu kopyala",
"status.popover.trigger": "Durum",
"status.popover.tools.trigger": "Araçlar",
"status.popover.tools.mcp.description": "opencode.json dosyasına MCP sunucuları ekleyin",
"status.popover.tools.lsp.description": "Dosyaları açtığınızda LSP'ler otomatik olarak algılanır",
"status.popover.tools.plugins.description": "opencode.json dosyasına eklentiler ekleyin",
"status.popover.tools.mcp.empty": "Henüz yapılandırılmış MCP yok",
"status.popover.tools.lsp.empty": "Henüz etkin dil sunucusu yok",
"status.popover.tools.plugins.empty": "Henüz yapılandırılmış eklenti yok",
"status.popover.tools.reauthenticate": "Yeniden kimlik doğrula",
"status.popover.ariaLabel": "Sunucu yapılandırmaları",
"status.popover.tab.servers": "Sunucular",
"status.popover.tab.mcp": "MCP",
@@ -803,6 +811,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Masaüstü başlık çubuğunda terminal düğmesini göster",
"settings.general.row.showStatus.title": "Sunucu durumu",
"settings.general.row.showStatus.description": "Başlık çubuğunda sunucu durumu düğmesini göster",
"settings.general.row.showTools.title": "Araçlar menüsü",
"settings.general.row.showTools.description": "Başlık çubuğunda MCP, LSP ve eklentilere hızlı erişim göster",
"settings.general.row.mobileTitlebarBottom.title": "Alt gezinme",
"settings.general.row.mobileTitlebarBottom.description":
"Mobil cihazlarda başlık çubuğunu ve oturum sekmelerini ekranın altına yerleştir",
@@ -1026,6 +1036,8 @@ export const dict = {
"app.server.unreachable": "{{server}} sunucusuna ulaşılamadı",
"app.server.retrying": "Otomatik olarak tekrar deneniyor...",
"app.server.status.reconnecting": "{{server}} sunucusuna yeniden bağlanılıyor...",
"app.server.status.disconnected": "{{server}} sunucusuna bağlanılamadı",
"app.server.otherServers": "Diğer sunucular",
"dialog.server.add.usernamePlaceholder": "kullanıcı adı",
"dialog.server.add.passwordPlaceholder": "parola",
+12
View File
@@ -333,6 +333,8 @@ export const dict = {
"app.server.unreachable": "Не вдалося досягти {{server}}",
"app.server.retrying": "Автоматичне повторення...",
"app.server.status.reconnecting": "Повторне підключення до {{server}}...",
"app.server.status.disconnected": "Не вдалося підключитися до {{server}}",
"app.server.otherServers": "Інші сервери",
"dialog.server.title": "Сервери",
@@ -729,6 +731,14 @@ export const dict = {
"session.header.open.app.sublimeText": "Sublime Text",
"status.popover.trigger": "Статус",
"status.popover.tools.trigger": "Інструменти",
"status.popover.tools.mcp.description": "Додайте сервери MCP в opencode.json",
"status.popover.tools.lsp.description": "LSP визначаються автоматично під час відкриття файлів",
"status.popover.tools.plugins.description": "Додайте плагіни в opencode.json",
"status.popover.tools.mcp.empty": "MCP ще не налаштовано",
"status.popover.tools.lsp.empty": "Активних мовних серверів ще немає",
"status.popover.tools.plugins.empty": "Плагіни ще не налаштовано",
"status.popover.tools.reauthenticate": "Повторно автентифікуватися",
"status.popover.ariaLabel": "Конфігурації серверів",
"status.popover.tab.servers": "Сервери",
"status.popover.tab.mcp": "MCP",
@@ -889,6 +899,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Показувати кнопку термінала в заголовку робочого столу",
"settings.general.row.showStatus.title": "Статус сервера",
"settings.general.row.showStatus.description": "Показувати кнопку статусу сервера в заголовку",
"settings.general.row.showTools.title": "Меню інструментів",
"settings.general.row.showTools.description": "Показувати швидкий доступ до MCP, LSP і плагінів у заголовку",
"settings.general.row.mobileTitlebarBottom.title": "Нижня навігація",
"settings.general.row.mobileTitlebarBottom.description":
"Розмістити заголовок і вкладки сесії внизу екрана на мобільних пристроях",
+12
View File
@@ -675,6 +675,14 @@ export const dict = {
"session.header.open.copyPath": "复制路径",
"status.popover.trigger": "状态",
"status.popover.tools.trigger": "工具",
"status.popover.tools.mcp.description": "在 opencode.json 中添加 MCP 服务器",
"status.popover.tools.lsp.description": "打开文件时会自动检测 LSP",
"status.popover.tools.plugins.description": "在 opencode.json 中添加插件",
"status.popover.tools.mcp.empty": "尚未配置 MCP",
"status.popover.tools.lsp.empty": "尚无活动的语言服务器",
"status.popover.tools.plugins.empty": "尚未配置插件",
"status.popover.tools.reauthenticate": "重新认证",
"status.popover.ariaLabel": "服务器配置",
"status.popover.tab.servers": "服务器",
"status.popover.tab.mcp": "MCP",
@@ -786,6 +794,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "在桌面版标题栏中显示终端按钮",
"settings.general.row.showStatus.title": "服务器状态",
"settings.general.row.showStatus.description": "在标题栏中显示服务器状态按钮",
"settings.general.row.showTools.title": "工具菜单",
"settings.general.row.showTools.description": "在标题栏中显示 MCP、LSP 和插件的快捷入口",
"settings.general.row.mobileTitlebarBottom.title": "底部导航",
"settings.general.row.mobileTitlebarBottom.description": "在移动设备上将标题栏和会话标签页置于屏幕底部",
"settings.general.row.showCustomAgents.title": "自定义智能体",
@@ -999,6 +1009,8 @@ export const dict = {
"app.server.unreachable": "无法连接到 {{server}}",
"app.server.retrying": "正在自动重试...",
"app.server.status.reconnecting": "正在重新连接到 {{server}}...",
"app.server.status.disconnected": "无法连接到 {{server}}",
"app.server.otherServers": "其他服务器",
"dialog.server.add.usernamePlaceholder": "用户名",
"dialog.server.add.passwordPlaceholder": "密码",
+12
View File
@@ -673,6 +673,14 @@ export const dict = {
"session.header.open.copyPath": "複製路徑",
"status.popover.trigger": "狀態",
"status.popover.tools.trigger": "工具",
"status.popover.tools.mcp.description": "在 opencode.json 中新增 MCP 伺服器",
"status.popover.tools.lsp.description": "開啟檔案時會自動偵測 LSP",
"status.popover.tools.plugins.description": "在 opencode.json 中新增外掛程式",
"status.popover.tools.mcp.empty": "尚未設定 MCP",
"status.popover.tools.lsp.empty": "尚無作用中的語言伺服器",
"status.popover.tools.plugins.empty": "尚未設定外掛程式",
"status.popover.tools.reauthenticate": "重新驗證",
"status.popover.ariaLabel": "伺服器設定",
"status.popover.tab.servers": "伺服器",
"status.popover.tab.mcp": "MCP",
@@ -781,6 +789,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "在桌面標題列中顯示終端機按鈕",
"settings.general.row.showStatus.title": "伺服器狀態",
"settings.general.row.showStatus.description": "在標題列中顯示伺服器狀態按鈕",
"settings.general.row.showTools.title": "工具選單",
"settings.general.row.showTools.description": "在標題列中顯示 MCP、LSP 和外掛程式的快速入口",
"settings.general.row.mobileTitlebarBottom.title": "底部導覽",
"settings.general.row.mobileTitlebarBottom.description": "在行動裝置上將標題列和工作階段分頁置於畫面底部",
"settings.general.row.showCustomAgents.title": "自訂代理程式",
@@ -995,6 +1005,8 @@ export const dict = {
"app.server.unreachable": "無法連線至 {{server}}",
"app.server.retrying": "正在自動重試...",
"app.server.status.reconnecting": "正在重新連線至 {{server}}...",
"app.server.status.disconnected": "無法連線至 {{server}}",
"app.server.otherServers": "其他伺服器",
"dialog.server.add.usernamePlaceholder": "使用者名稱",
"dialog.server.add.passwordPlaceholder": "密碼",
@@ -2,11 +2,30 @@ import { describe, expect, test } from "bun:test"
import { shouldOpenSessionInBackground } from "./home-session-open"
describe("shouldOpenSessionInBackground", () => {
test("opens middle clicks in the background", () => {
expect(
shouldOpenSessionInBackground({ button: 1, mac: true, meta: false, ctrl: false, shift: false, alt: false }),
).toBe(true)
expect(
shouldOpenSessionInBackground({ button: 2, mac: true, meta: false, ctrl: false, shift: false, alt: false }),
).toBe(false)
})
test("requires only the platform primary modifier", () => {
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true)
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true)
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false)
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false)
expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false)
expect(
shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: false, alt: false }),
).toBe(true)
expect(
shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: false }),
).toBe(true)
expect(
shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: true, alt: false }),
).toBe(false)
expect(
shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: true }),
).toBe(false)
expect(
shouldOpenSessionInBackground({ button: 0, mac: false, meta: true, ctrl: false, shift: false, alt: false }),
).toBe(false)
})
})
@@ -1,10 +1,13 @@
export function shouldOpenSessionInBackground(input: {
button: number
mac: boolean
meta: boolean
ctrl: boolean
shift: boolean
alt: boolean
}) {
if (input.button === 1) return true
if (input.button !== 0) return false
if (input.shift || input.alt) return false
if (input.mac) return input.meta && !input.ctrl
return input.ctrl && !input.meta
+79 -27
View File
@@ -40,7 +40,7 @@ import { DialogSelectServer, useServerManagementController } from "@/components/
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { ServerConnection, serverName, useServer } from "@/context/server"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import {
@@ -50,7 +50,6 @@ import {
getProjectAvatarSource,
homeProjectDirectories,
projectForSession,
sortedRootSessions,
toggleHomeProjectSelection,
} from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
@@ -69,6 +68,11 @@ import { archiveHomeSession } from "./home-session-archive"
import { shouldOpenSessionInBackground } from "./home-session-open"
import { showToast } from "@/utils/toast"
import { fileManagerApp } from "@/utils/file-manager"
import {
loadHomeSessionIndex,
retainHomeSessions,
type HomeSessionEvents,
} from "@/context/global-sync/home-session-index"
const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12
@@ -106,22 +110,24 @@ const HOME_SEARCH_RESULT_META =
let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
function buildHomeSessionRecords(input: {
sync: Pick<ServerSync, "child">
sessions: () => Session[]
projectDirectories: () => string[]
projects: () => LocalProject[]
projectByID: () => Map<string, LocalProject>
}) {
return [
...new Map(
input
.projectDirectories()
.flatMap((directory) => sortedRootSessions(input.sync.child(directory, { bootstrap: false })[0], Date.now()))
.map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const),
).values(),
]
const directories = new Set(input.projectDirectories().map(pathKey))
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.flatMap((session) => {
const project = projectForSession(session, input.projects(), input.projectByID())
const directory = pathKey(session.directory)
const project =
input
.projects()
.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? projectForSession(session, input.projects(), input.projectByID())
if (!project) return []
return {
session,
@@ -240,10 +246,11 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
}
// Cmd+click on macOS (Ctrl+click elsewhere) opens a session tab in the
// background without navigating, matching browser conventions.
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
// tab in the background without navigating, matching browser conventions.
function isBackgroundOpen(event: MouseEvent) {
return shouldOpenSessionInBackground({
button: event.button,
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
meta: event.metaKey,
ctrl: event.ctrlKey,
@@ -285,6 +292,7 @@ export function NewHome() {
return global.ensureServerCtx(conn)
})
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
const homeSessions = () => focusedSync().homeSessions
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
const recentlyClosed = createMemo(
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
@@ -317,24 +325,47 @@ export function NewHome() {
}
return language.t("home.sessions.search.placeholder")
})
const sessionEventLoad = useQuery(() => ({
queryKey: homeSessions().eventsKey,
queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
enabled: false,
}))
const sessionLoad = useQuery(() => ({
queryKey: ["home", "sessions", selection().server, ...projectDirectories()] as const,
queryFn: async () => {
await Promise.all(
projectDirectories().map((directory) =>
focusedSync().project.loadSessions(directory, { limit: HOME_SESSION_LIMIT }),
),
queryKey: homeSessions().indexKey,
enabled: !!focusedServerCtx(),
queryFn: async ({ signal }) => {
const ctx = focusedServerCtx()
if (!ctx) return { sessions: [], eventSequence: 0 }
const cache = homeSessions()
const eventSequence = cache.eventSequence()
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.client.v2.session.list(input, options),
eventSequence,
signal,
)
return null
cache.complete(eventSequence)
return index
},
retry: false,
staleTime: 30_000,
refetchOnMount: true,
refetchOnReconnect: true,
}))
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const indexedSessions = createMemo(() =>
retainHomeSessions(
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
HOME_SESSION_LIMIT,
Date.now(),
),
)
const allRecords = createMemo(() =>
buildHomeSessionRecords({
sync: focusedSync(),
sessions: indexedSessions,
projectDirectories,
projects,
projectByID,
@@ -362,8 +393,7 @@ export function NewHome() {
prefetched.add(key)
createRoot((dispose) => {
try {
const directory = ctx.sync.ensureDirSyncContext(record.session.directory)
void directory.session
void ctx.sync.session
.sync(record.session.id)
.then(() => {
return Promise.all(
@@ -465,8 +495,8 @@ export function NewHome() {
}
function editProject(conn: ServerConnection.Any, project: LocalProject) {
void import("@/components/dialog-edit-project").then((x) => {
dialog.show(() => <x.DialogEditProject server={conn} project={project} />)
void import("@/components/dialog-edit-project-v2").then((x) => {
void dialog.show(() => <x.DialogEditProjectV2 server={conn} project={project} />)
})
}
@@ -483,7 +513,13 @@ export function NewHome() {
}
function openSession(session: Session, options?: OpenSessionOptions) {
const project = projectForSession(session, projects(), projectByID())
const directoryKey = pathKey(session.directory)
const project =
projects().find(
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
) ?? projectForSession(session, projects(), projectByID())
const conn = focusedServer()
if (!conn) return
const directory = project?.worktree ?? session.directory
@@ -1386,7 +1422,15 @@ function HomeSessionSearchResultRow(props: {
group: !!showProjectName(),
}}
onMouseEnter={() => props.onHighlight()}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.onSelect(props.record.session, { background: true })
}}
>
<HomeSessionLeading
project={props.record.project}
@@ -1446,7 +1490,15 @@ function HomeSessionRow(props: {
type="button"
data-component="home-session-row"
class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.openSession(props.record.session, { background: true })
}}
>
<HomeSessionLeading
project={props.record.project}
-16
View File
@@ -1,13 +1,10 @@
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useSearchParams } from "@solidjs/router"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { NewSessionDesignView } from "@/components/session"
import { PromptInput } from "@/components/prompt-input"
import { StatusPopoverV2 } from "@/components/status-popover"
import {
PromptProjectAddButton,
PromptProjectSelector,
@@ -25,7 +22,6 @@ import { useSessionKey } from "@/pages/session/session-layout"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
import { useTitlebarRightMount } from "@/components/titlebar"
import { useCommand } from "@/context/command"
import { useProviders } from "@/hooks/use-providers"
import { useSettingsDialog } from "@/components/settings-dialog"
@@ -86,7 +82,6 @@ export default function NewSessionPage() {
])
const [store, setStore] = createStore<{ worktree?: string }>({})
const rightMount = useTitlebarRightMount()
const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
const newSessionWorktree = createMemo(() => {
@@ -126,17 +121,6 @@ export default function NewSessionPage() {
return (
<div class="relative size-full overflow-hidden flex flex-col">
<Show when={rightMount()}>
{(mount) => (
<Portal mount={mount()}>
<Show when={settings.visibility.status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopoverV2 />
</Tooltip>
</Show>
</Portal>
)}
</Show>
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
+40 -3
View File
@@ -1,4 +1,5 @@
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
import {
@@ -57,6 +58,8 @@ import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import { TerminalProvider, useTerminal } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input"
import { setCursorPosition } from "@/components/prompt-input/editor-dom"
import { promptLength } from "@/components/prompt-input/history"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import {
createPromptInputController,
@@ -79,6 +82,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds"
@@ -1052,7 +1056,10 @@ export default function Page() {
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
if (composer.blocked() || isChildSession()) return
inputRef?.focus()
const input = inputRef
if (!input) return
input.focus()
setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current()))
}
}
@@ -1904,7 +1911,30 @@ export default function Page() {
.map((item) => ({ id: item.id, text: line(item.id) }))
})
const actions = { revert }
// attachment bytes are embedded as a data URL, so downloading always works;
// revealing requires the on-disk path captured by the client that attached the file
const openAttachment = (file: FilePart) => {
const download = () => {
const anchor = document.createElement("a")
anchor.href = file.url
anchor.download = getFilename(file.filename) || "attachment"
anchor.click()
}
const path = file.filename ?? ""
const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
if (platform.revealPath && absolute) {
void platform.revealPath(path).then(
(revealed) => {
if (!revealed) download()
},
() => download(),
)
return
}
download()
}
const actions = { revert, openAttachment }
createEffect(() => {
const sessionID = params.id
@@ -2276,6 +2306,13 @@ export default function Page() {
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
reviewCount={reviewCount}
reviewPanel={reviewPanelV2}
reviewSidebarToggle={(disabled) => (
<SessionReviewV2SidebarToggle
opened={reviewV2State.sidebarOpened()}
disabled={disabled}
onToggle={reviewV2State.toggleSidebar}
/>
)}
fileBrowserState={reviewV2State}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
+329 -1
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -6,9 +6,12 @@ import type { FileSearchHandle } from "@opencode-ai/session-ui/file"
import { useFileComponent } from "@opencode-ai/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations"
import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2"
import { sampledChecksum } from "@opencode-ai/core/util/encode"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { showToast } from "@/utils/toast"
@@ -16,6 +19,7 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange
import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { useSettings } from "@/context/settings"
import { getSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
@@ -53,6 +57,30 @@ function FileCommentMenu(props: {
)
}
function FileCommentMenuV2(props: {
moreLabel: string
editLabel: string
deleteLabel: string
onEdit: VoidFunction
onDelete: VoidFunction
}) {
return (
<div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<MenuV2 gutter={4}>
<MenuV2.Trigger as="button" type="button" data-slot="line-comment-v2-overflow" aria-label={props.moreLabel}>
<LineCommentV2OverflowIcon />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={props.onEdit}>{props.editLabel}</MenuV2.Item>
<MenuV2.Item onSelect={props.onDelete}>{props.deleteLabel}</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</div>
)
}
type ScrollPos = { x: number; y: number }
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
@@ -180,6 +208,15 @@ export function FileTabContent(props: { tab: string }) {
}
export function SessionFileView(props: { tab: string }) {
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={<SessionFileViewV1 tab={props.tab} />}>
<SessionFileViewV2 tab={props.tab} />
</Show>
)
}
function SessionFileViewV1(props: { tab: string }) {
const file = useFile()
const comments = useComments()
const language = useLanguage()
@@ -463,3 +500,294 @@ export function SessionFileView(props: { tab: string }) {
return content()
}
function SessionFileViewV2(props: { tab: string }) {
const file = useFile()
const comments = useComments()
const language = useLanguage()
const prompt = usePrompt()
const fileComponent = useFileComponent()
const { sessionKey, tabs, view } = useSessionLayout()
const activeFileTab = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}).activeFileTab
let find: FileSearchHandle | null = null
const search = {
register: (handle: FileSearchHandle | null) => {
find = handle
},
}
const path = createMemo(() => file.pathFromTab(props.tab))
const state = createMemo(() => {
const p = path()
if (!p) return
return file.get(p)
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents()))
const selectedLines = createMemo<SelectedLineRange | null>(() => {
const p = path()
if (!p) return null
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
})
const scrollSync = createScrollSync({
tab: () => props.tab,
view,
})
const selectionPreview = (source: string, selection: FileSelection) => {
return previewSelectedLines(source, {
start: selection.startLine,
end: selection.endLine,
})
}
const buildPreview = (filePath: string, selection: FileSelection) => {
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => {
const selection = selectionFromLines(input.selection)
const preview = input.preview ?? buildPreview(input.file, selection)
const saved = comments.add({
file: input.file,
selection: input.selection,
comment: input.comment,
})
prompt.context.add({
type: "file",
path: input.file,
selection,
comment: input.comment,
commentID: saved.id,
commentOrigin: input.origin,
preview,
})
}
const updateCommentInContext = (input: {
id: string
file: string
selection: SelectedLineRange
comment: string
}) => {
comments.update(input.file, input.id, input.comment)
const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
prompt.context.updateComment(input.file, input.id, {
comment: input.comment,
...(preview ? { preview } : {}),
})
}
const removeCommentFromContext = (input: { id: string; file: string }) => {
comments.remove(input.file, input.id)
prompt.context.removeComment(input.file, input.id)
}
const fileComments = createMemo(() => {
const p = path()
if (!p) return []
return comments.list(p)
})
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
const [note, setNote] = createStore({
openedComment: null as string | null,
commenting: null as SelectedLineRange | null,
selected: null as SelectedLineRange | null,
})
const syncSelected = (range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null)
}
const activeSelection = () => note.selected ?? selectedLines()
const commentsUi = createLineCommentControllerV2({
comments: fileComments,
label: language.t("ui.lineComment.submit"),
draftKey: () => path() ?? props.tab,
mention: {
items: file.searchFilesAndDirectories,
},
getSide: (range) => range.endSide ?? range.side ?? "additions",
state: {
opened: () => note.openedComment,
setOpened: (id) => setNote("openedComment", id),
selected: () => note.selected,
setSelected: (range) => setNote("selected", range),
commenting: () => note.commenting,
setCommenting: (range) => setNote("commenting", range),
syncSelected,
hoverSelected: syncSelected,
},
onSubmit: ({ comment, selection }) => {
const p = path()
if (!p) return
addCommentToContext({ file: p, selection, comment, origin: "file" })
},
onUpdate: ({ id, comment, selection }) => {
const p = path()
if (!p) return
updateCommentInContext({ id, file: p, selection, comment })
},
onDelete: (comment) => {
const p = path()
if (!p) return
removeCommentFromContext({ id: comment.id, file: p })
},
editSubmitLabel: language.t("common.save"),
renderCommentActions: (_, controls) => (
<FileCommentMenuV2
moreLabel={language.t("common.moreOptions")}
editLabel={language.t("common.edit")}
deleteLabel={language.t("common.delete")}
onEdit={controls.edit}
onDelete={controls.remove}
/>
),
})
createEffect(() => {
if (typeof window === "undefined") return
const onKeyDown = (event: KeyboardEvent) => {
if (activeFileTab() !== props.tab) return
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
if (event.key.toLowerCase() !== "f") return
event.preventDefault()
event.stopPropagation()
find?.focus()
}
makeEventListener(window, "keydown", onKeyDown, { capture: true })
})
createEffect(
on(
path,
() => {
commentsUi.note.reset()
},
{ defer: true },
),
)
createEffect(() => {
const focus = comments.focus()
const p = path()
if (!focus || !p) return
if (focus.file !== p) return
if (activeFileTab() !== props.tab) return
const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return
commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true })
requestAnimationFrame(() => comments.clearFocus())
})
let prev = {
loaded: false,
ready: false,
active: false,
}
createEffect(() => {
const loaded = !!state()?.loaded
const ready = file.ready()
const active = activeFileTab() === props.tab
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
prev = { loaded, ready, active }
if (!restore) return
scrollSync.queueRestore()
})
const renderFile = (source: string) => (
<div class="relative overflow-hidden pb-40">
<Dynamic
component={fileComponent}
mode="text"
file={{
name: path() ?? "",
contents: source,
cacheKey: cacheKey(),
}}
enableLineSelection
enableGutterUtility
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
scrollSync.queueRestore()
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderGutterUtility={commentsUi.renderGutterUtility}
onLineSelected={(range: SelectedLineRange | null) => {
commentsUi.onLineSelected(range)
}}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) {
commentsUi.note.select(null)
commentsUi.note.cancelDraft()
return
}
commentsUi.onLineSelectionEnd(range)
}}
onLineNumberSelectionEnd={(range: SelectedLineRange | null) => {
commentsUi.onLineNumberSelectionEnd(range)
}}
search={search}
class="select-text"
media={{
mode: "auto",
path: path(),
current: state()?.content,
onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
})
},
}}
/>
</div>
)
const content = () => (
<div class="mt-3 relative h-full min-h-0">
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</ScrollView>
</div>
)
return content()
}
@@ -1,28 +1,46 @@
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import {
DragDropProvider,
DragDropSensors,
DragOverlay,
SortableProvider,
closestCenter,
type DragEvent,
} from "@thisbeyond/solid-dnd"
import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import FileTree from "@/components/file-tree"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
import { SessionContextUsage } from "@/components/session-context-usage"
const reviewTabID = "session-side-panel-review-tab"
const reviewTabPanelID = "session-side-panel-review-tabpanel"
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session"
import { OpenInAppV2 } from "@/components/session/open-in-app-v2"
import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
@@ -53,6 +71,7 @@ export function SessionSidePanel(props: {
reviewHasFocusableContent: () => boolean
reviewCount: () => number
reviewPanel: () => JSX.Element
reviewSidebarToggle?: (disabled: boolean) => JSX.Element
fileBrowserState?: SessionFileBrowserState
activeDiff?: string
focusReviewDiff: (path: string) => void
@@ -66,7 +85,9 @@ export function SessionSidePanel(props: {
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
const sdk = useSDK()
const { sessionKey, tabs, view, params } = useSessionLayout()
const projectDirectory = createMemo(() => sdk().directory)
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = settings.visibility.fileTree
@@ -98,11 +119,9 @@ export function SessionSidePanel(props: {
return "mix" as const
}
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
const file = normalize(diff.file)
const file = normalizeFileTreeV2Path(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
@@ -159,6 +178,7 @@ export function SessionSidePanel(props: {
fileBrowser: () => !!props.fileBrowserState,
})
const contextOpen = tabState.contextOpen
const openFileOpen = tabState.openFileOpen
const panelTabs = tabState.panelTabs
const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab
@@ -176,10 +196,8 @@ export function SessionSidePanel(props: {
layout.fileTree.setTab("all")
}
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
})
let fileFilter: HTMLInputElement | undefined
let tabList: HTMLDivElement | undefined
const temporaryTab = tabs().preview
const previewTab = (value: string) => {
const next = normalizeTab(value)
@@ -202,10 +220,26 @@ export function SessionSidePanel(props: {
}
const browserTab = createMemo(() => {
if (!props.fileBrowserState) return undefined
if (activeTab() === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
const active = activeTab()
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
if (active && file.pathFromTab(active)) return active
return activeFileTab()
})
const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
// Keep the file-browser shell mounted while any file tab exists. Kobalte briefly
// selects Review while the tab For replaces a preview trigger, which would
// otherwise dispose the sidebar and reset scroll.
const fileBrowserMounted = createMemo(() => {
if (!props.fileBrowserState) return false
return openedTabs().length > 0 || openFileOpen() || !!browserTab()
})
const fileBrowserVisible = createMemo(() => {
const active = activeTab()
return active !== "review" && active !== "context" && active !== "empty"
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
})
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
@@ -291,72 +325,283 @@ export function SessionSidePanel(props: {
"bg-background-base": !settings.general.newLayoutDesigns(),
}}
>
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={activateTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
<Show
when={props.fileBrowserState}
fallback={
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={activateTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={panelTabs()}>
{(tab) => (
<Show
when={tab === SESSION_OPEN_FILE_TAB}
fallback={
<SortableTab
tab={tab}
temporary={temporaryTab() === tab}
onTabClose={tabs().close}
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
/>
}
>
<Tabs.Trigger
value={SESSION_OPEN_FILE_TAB}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
>
<div class="flex items-center gap-1.5 italic">
<Icon name="open-file" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Tabs.Trigger>
</Show>
)}
</For>
</SortableProvider>
<div
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="close-small"
icon="plus-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
iconSize="large"
class="!rounded-md"
onClick={() => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
}}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Tabs.List>
</div>
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
>
{props.reviewPanel()}
</div>
</Show>
<SortableProvider ids={openedTabs()}>
<Show when={activeTab() === "empty"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</Tabs.Content>
</Show>
<Show when={activeTab() === "context"}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Tabs.Content>
</Show>
<Show when={activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = file.pathFromTab(tab)
return (
<div data-component="tabs-drag-preview">
<Show when={path}>
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
}
>
<DndKitProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
event.target instanceof Element &&
(!!event.target.closest('[data-slot="tabs-trigger-close-button"]') ||
!!event.target.closest(".session-review-v2-open-in-app-slot")),
}),
]}
modifiers={[
RestrictToHorizontalAxis,
RestrictToElement.configure({ element: () => tabList ?? null }),
]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== Accessibility),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return
tabs().move(source.id.toString(), source.index)
}}
>
<Tabs value={activeTab()} onChange={activateTab}>
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
<Tabs.List
ref={(el: HTMLDivElement) => {
tabList = el
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={props.reviewSidebarToggle}>
{(toggle) => (
<div class="h-full shrink-0 flex items-center justify-center">
{toggle()(activeTab() === SESSION_OPEN_FILE_TAB)}
</div>
)}
</Show>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
>
{props.hasReview()
? language.t("session.review.filesChanged", { count: props.reviewCount() })
: language.t("session.tab.review")}
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<For each={panelTabs()}>
{(tab) => (
<Show
when={tab === SESSION_OPEN_FILE_TAB}
fallback={
<SortableTab
<SortableTabV2
tab={tab}
index={() => tabs().all().indexOf(tab)}
temporary={temporaryTab() === tab}
onTabClose={tabs().close}
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
@@ -392,106 +637,104 @@ export function SessionSidePanel(props: {
</Show>
)}
</For>
</SortableProvider>
<div
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
<div
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() => {
if (props.fileBrowserState) {
openFileBrowser()
return
}
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
}}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
<TooltipV2
value={
<>
{language.t("command.file.open")}
<Show when={openFileKeybind().length > 0}>
<KeybindV2 keys={openFileKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
class="flex items-center"
>
<IconButtonV2
icon={<Icon name="plus-small" />}
variant="ghost-muted"
size="large"
onClick={() => openFileBrowser()}
aria-label={language.t("command.file.open")}
/>
</TooltipV2>
</div>
</Tabs.List>
<div
class="session-review-v2-open-in-app-slot shrink-0 flex items-center pr-3"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<OpenInAppV2 directory={projectDirectory} />
</div>
</Tabs.List>
</div>
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
>
{props.reviewPanel()}
</div>
</Show>
<Show when={activeTab() === "empty"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
>
{props.reviewPanel()}
</div>
</Show>
<Show when={activeTab() === "empty"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</div>
</Tabs.Content>
</Show>
</Tabs.Content>
</Show>
<Show when={activeTab() === "context"}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Tabs.Content>
</Show>
<Show when={browserTab()}>
<SessionFileBrowserTab
tab={browserTab()!}
placeholder={browserTab() === SESSION_OPEN_FILE_TAB}
active={file.pathFromTab(browserTab()!)}
kinds={browserKinds()}
state={props.fileBrowserState!}
onSelect={(path) => previewTab(file.tab(path))}
onSelectPermanent={(path) => openTab(file.tab(path))}
filterRef={(element) => (fileFilter = element)}
/>
</Show>
<Show when={!props.fileBrowserState && activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = file.pathFromTab(tab)
return (
<div data-component="tabs-drag-preview">
<Show when={path}>
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
</Show>
<Show when={activeTab() === "context"}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</Tabs.Content>
</Show>
<Show when={fileBrowserMounted()}>
<div
id={fileBrowserTabPanelID}
role="tabpanel"
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden"
classList={{ hidden: !fileBrowserVisible() }}
inert={!fileBrowserVisible() || undefined}
>
<SessionFileBrowserTab
tab={browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
placeholder={
(browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
}
active={file.pathFromTab(browserTab() ?? activeFileTab() ?? "")}
kinds={kinds()}
state={props.fileBrowserState!}
onSelect={(path) => previewTab(file.tab(path))}
onSelectPermanent={(path) => openTab(file.tab(path))}
filterRef={(element) => (fileFilter = element)}
/>
</div>
</Show>
</Tabs>
</DndKitProvider>
</Show>
</div>
</div>
</Show>
@@ -519,10 +762,19 @@ export function SessionSidePanel(props: {
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{props.reviewCount()}{" "}
{language.t(
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
)}
<Show
when={settings.general.newLayoutDesigns()}
fallback={
<>
{props.reviewCount()}{" "}
{language.t(
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
)}
</>
}
>
{language.t("session.review.filesChanged", { count: props.reviewCount() })}
</Show>
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
@@ -327,6 +327,7 @@ export function MessageTimeline(props: {
parts: getMsgParts,
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
inlineComments: settings.general.newLayoutDesigns,
})
const activeMessageID = projection.activeMessageID
const assistantMessagesByParent = projection.assistantMessagesByParent
@@ -1135,6 +1136,10 @@ export function MessageTimeline(props: {
const m = messageByID().get(userMessageRow().userMessageID)
if (m?.role === "user") return m
})
const messageComments = createMemo(() => {
if (!settings.general.newLayoutDesigns()) return []
return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
})
return (
<TimelineRowFrame row={userMessageRow}>
<Show when={message()}>
@@ -1146,6 +1151,7 @@ export function MessageTimeline(props: {
parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions}
useV2Actions={settings.general.newLayoutDesigns()}
comments={messageComments()}
/>
</div>
</div>
@@ -14,6 +14,7 @@ export function createTimelineProjection(input: {
parts: (messageID: string) => Part[]
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
inlineComments: Accessor<boolean>
}) {
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
const assistantMessagesByParent = createMemo(() => {
@@ -59,6 +60,7 @@ export function createTimelineProjection(input: {
input.showReasoningSummaries(),
input.status().type,
activeMessageID() === userMessage.id,
input.inlineComments(),
),
),
),
@@ -38,6 +38,8 @@ export namespace Timeline {
showReasoning: boolean,
status: SessionStatus["type"],
isActive: boolean,
// v2 renders comments inside the user message attachments row instead of a strip row
inlineComments: boolean,
) {
const rows: TimelineRow.TimelineRow[] = []
@@ -74,7 +76,7 @@ export namespace Timeline {
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
if (comments.length > 0)
if (comments.length > 0 && !inlineComments)
rows.push(
new TimelineRow.CommentStrip({
userMessageID: userMessage.id,
@@ -84,7 +86,7 @@ export namespace Timeline {
rows.push(
new TimelineRow.UserMessage({
userMessageID: userMessage.id,
anchor: comments.length === 0,
anchor: inlineComments || comments.length === 0,
}),
)
@@ -5,7 +5,6 @@ import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
SessionReviewV2,
SessionReviewV2Sidebar,
SessionReviewV2SidebarToggle,
} from "@opencode-ai/session-ui/v2/session-review-v2"
import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2"
import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
@@ -66,6 +65,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
)
const searching = createMemo(() => props.state.filter().trim().length > 0)
const kinds = createMemo(() => reviewDiffKinds(diffs()))
// Changes-only trees omit "M" — every row is already a change; A/D stay visible.
const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
const activeDiff = createMemo(() => {
// A focused comment takes over the preview until the preview applies it and
// clears the focus; the owner then persists the file as the active selection.
@@ -113,9 +114,6 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
stats={<DiffChanges changes={diffs()} />}
empty={props.empty}
sidebarOpen={props.state.sidebarOpened()}
sidebarToggle={
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
}
sidebar={
// Always mounted: the sidebar header hosts the changes-mode dropdown,
// which must stay reachable when the current mode has zero diffs.
@@ -127,7 +125,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
diffs={diffs}
filteredFiles={filteredFiles}
searching={searching}
kinds={kinds}
kinds={treeKinds}
activeDiff={activeDiff}
/>
}
@@ -1,14 +1,9 @@
import { createMemo, createSignal, createUniqueId, Show } from "solid-js"
import { createQuery } from "@tanstack/solid-query"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import {
SessionFilePanelV2,
SessionFilePanelV2Empty,
SessionFilePanelV2Title,
} from "@opencode-ai/session-ui/v2/session-file-panel-v2"
import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
import FileTree, { type Kind } from "@/components/file-tree"
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode-ai/session-ui/v2/session-file-panel-v2"
import { SessionReviewV2Sidebar } from "@opencode-ai/session-ui/v2/session-review-v2"
import FileTreeV2, { type Kind } from "@/components/file-tree-v2"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
@@ -93,102 +88,92 @@ export function SessionFileBrowserTab(props: {
})
}
// Keep the sidebar outside Kobalte Tabs.Content: a morphing content value
// unmounts the whole panel on every file-tab switch and resets sidebar scroll.
return (
<Tabs.Content value={props.tab} class="h-full min-h-0 overflow-hidden">
<SessionFilePanelV2
toolbar
toolbarStart={
<>
<SessionReviewV2SidebarToggle opened={sidebarOpened()} onToggle={props.state.toggleSidebar} />
<Show when={!sidebarOpened()}>
<SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title>
</Show>
</>
}
sidebar={
<SessionReviewV2Sidebar
open={sidebarOpened()}
title={<span class="truncate">{title()}</span>}
filter={filter()}
onFilterChange={setFilter}
onFilterKeyDown={onFilterKeyDown}
filterAutofocus={props.placeholder}
filterRef={props.filterRef}
filterControls={resultsID}
filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
filterExpanded={query().length > 0 && files().length > 0}
width={props.state.sidebarWidth()}
onWidthChange={props.state.resizeSidebar}
<SessionFilePanelV2
toolbar={false}
sidebar={
<SessionReviewV2Sidebar
open={sidebarOpened()}
title={<span class="truncate">{title()}</span>}
filter={filter()}
onFilterChange={setFilter}
onFilterKeyDown={onFilterKeyDown}
filterAutofocus={props.placeholder}
filterRef={props.filterRef}
filterControls={resultsID}
filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
filterExpanded={query().length > 0 && files().length > 0}
width={props.state.sidebarWidth()}
onWidthChange={props.state.resizeSidebar}
>
<Show
when={query()}
fallback={
<FileTreeV2
active={props.active}
kinds={props.kinds}
onFileClick={(node) => props.onSelect(node.path)}
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
/>
}
>
<Show
when={query()}
when={!loading()}
fallback={
<FileTree
path=""
class="pt-1"
active={props.active}
kinds={props.kinds}
onFileClick={(node) => props.onSelect(node.path)}
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
/>
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Show
when={!loading()}
when={files().length > 0}
fallback={
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
{language.t("palette.empty")}
</div>
}
>
<Show
when={files().length > 0}
fallback={
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
{language.t("palette.empty")}
</div>
}
>
<SessionFileListV2
id={resultsID}
role="listbox"
optionID={optionID}
files={files()}
kinds={props.kinds}
active={props.active}
highlighted={highlighted()}
onFileClick={(path) => {
setExplicitHighlight(path)
props.onSelect(path)
}}
onFileDoubleClick={props.onSelectPermanent}
/>
</Show>
<SessionFileListV2
id={resultsID}
role="listbox"
optionID={optionID}
files={files()}
kinds={props.kinds}
active={props.active}
highlighted={highlighted()}
onFileClick={(path) => {
setExplicitHighlight(path)
props.onSelect(path)
}}
onFileDoubleClick={props.onSelectPermanent}
/>
</Show>
</Show>
</SessionReviewV2Sidebar>
</Show>
</SessionReviewV2Sidebar>
}
>
<Show
when={!props.placeholder}
fallback={
<SessionFilePanelV2Empty>
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
<Icon name="file-tree" size="large" />
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
</div>
</SessionFilePanelV2Empty>
}
>
<Show
when={!props.placeholder}
fallback={
<SessionFilePanelV2Empty>
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
<Icon name="file-tree" size="large" />
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
</div>
</SessionFilePanelV2Empty>
}
>
<div class="min-h-0 flex-1">
<Show when={props.tab} keyed>
{(tab) => <SessionFileView tab={tab} />}
</Show>
</div>
</Show>
</SessionFilePanelV2>
</Tabs.Content>
<div class="min-h-0 flex-1">
<Show when={props.tab} keyed>
{(tab) => <SessionFileView tab={tab} />}
</Show>
</div>
</Show>
</SessionFilePanelV2>
)
}
+44 -17
View File
@@ -1,8 +1,32 @@
import { expect, test } from "bun:test"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { createVirtualizer, defaultRangeExtractor, Virtualizer } from "@tanstack/solid-virtual"
import { createRoot, createSignal } from "solid-js"
import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items"
test("end anchoring survives consecutive resizes when the first scroll write is clamped", () => {
const writes: { offset: number; adjustments?: number }[] = []
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>({
count: 5,
estimateSize: () => 50,
initialOffset: 50,
initialRect: { width: 400, height: 200 },
anchorTo: "end",
scrollEndThreshold: 1,
getScrollElement: () => null,
scrollToFn: (offset, options) => writes.push({ offset, adjustments: options.adjustments }),
observeElementRect: () => {},
observeElementOffset: () => {},
})
virtualizer.getTotalSize()
virtualizer.resizeItem(4, 120)
expect(writes).toEqual([{ offset: 50, adjustments: 70 }])
writes.length = 0
virtualizer.resizeItem(4, 200)
expect(writes).toEqual([{ offset: 120, adjustments: 80 }])
})
test("reactive count updates preserve measured row sizes", () => {
createRoot((dispose) => {
const [count, setCount] = createSignal(2)
@@ -42,23 +66,26 @@ test("initial rect projects rows before a scroll element connects", () => {
})
})
test("logical scroll offset includes pending measurement adjustments", () => {
createRoot((dispose) => {
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
count: 2,
getScrollElement: () => null,
estimateSize: () => 60,
initialOffset: 100,
initialRect: { width: 800, height: 60 },
})
virtualizer.getTotalSize()
virtualizer.resizeItem(0, 100)
expect(virtualizer.scrollOffset).toBe(100)
expect(virtualizer.getLogicalScrollOffset()).toBe(140)
dispose()
test("clamps oversized offsets with scroll margin and padding changes", () => {
const options = (paddingEnd: number) => ({
count: 20,
estimateSize: () => 60,
initialOffset: Number.MAX_SAFE_INTEGER,
initialRect: { width: 800, height: 600 },
scrollMargin: 64,
paddingEnd,
overscan: 1,
getScrollElement: () => null,
scrollToFn: () => {},
observeElementRect: () => {},
observeElementOffset: () => {},
})
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options(64))
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
virtualizer.setOptions(options(600))
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([18, 19])
})
test("stale pinned indexes do not produce missing virtual items after count shrinks", () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.17.18",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.17.18",
"version": "1.17.20",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.17.18",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.17.18",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.17.18",
"version": "1.17.20",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.17.18",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"scripts": {
+4 -4
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.18",
"version": "1.17.20",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -64,7 +64,7 @@
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/azure": "3.0.88",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
@@ -73,7 +73,7 @@
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
@@ -108,7 +108,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.10.0",
"gitlab-ai-provider": "6.11.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
+16
View File
@@ -44,6 +44,21 @@ const Cost = Schema.Struct({
),
})
const ReasoningOption = Schema.Union([
Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.NullOr(Schema.String)),
}),
Schema.Struct({
type: Schema.Literal("toggle"),
}),
Schema.Struct({
type: Schema.Literal("budget_tokens"),
min: Schema.optional(Schema.Finite),
max: Schema.optional(Schema.Finite),
}),
])
export const Model = Schema.Struct({
id: Schema.String,
name: Schema.String,
@@ -53,6 +68,7 @@ export const Model = Schema.Struct({
reasoning: Schema.Boolean,
temperature: Schema.Boolean,
tool_call: Schema.Boolean,
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
interleaved: Schema.optional(
Schema.Union([
Schema.Literal(true),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.17.18",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+10
View File
@@ -184,6 +184,16 @@ export function registerIpcHandlers(deps: Deps) {
})
})
ipcMain.handle("reveal-path", async (_event: IpcMainInvokeEvent, path: string) => {
const exists = await stat(path).then(
() => true,
() => false,
)
if (!exists) return false
shell.showItemInFolder(path)
return true
})
ipcMain.handle("read-clipboard-image", () => {
const image = clipboard.readImage()
if (image.isEmpty()) return null
+1
View File
@@ -95,6 +95,7 @@ const api: ElectronAPI = {
saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
openLink: (url) => ipcRenderer.send("open-link", url),
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
revealPath: (path) => ipcRenderer.invoke("reveal-path", path),
readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"),
showNotification: (title, body) => ipcRenderer.send("show-notification", title, body),
getWindowFocused: () => ipcRenderer.invoke("get-window-focused"),
+1
View File
@@ -86,6 +86,7 @@ export type ElectronAPI = {
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
openLink: (url: string) => void
openPath: (path: string, app?: string) => Promise<void>
revealPath: (path: string) => Promise<boolean>
readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
showNotification: (title: string, body?: string) => void
getWindowFocused: () => Promise<boolean>
+3
View File
@@ -218,6 +218,9 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
}
return window.api.openPath(path, app)
},
async revealPath(path: string) {
return window.api.revealPath(path)
},
back() {
window.history.back()

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