mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-30 07:01:23 -04:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1140d8422e | |||
| 9ecfb847fa | |||
| 588288ce87 | |||
| 0156d3028f | |||
| 5e5e305bb7 | |||
| d87f987800 | |||
| 545b86587d | |||
| 7fb3fbdf27 | |||
| 31f946eed7 | |||
| 45daa7fece | |||
| a5bbe636e7 | |||
| 2e1ce87bf3 | |||
| 56e8a4f7d7 |
@@ -18,4 +18,4 @@ simonklee
|
||||
Slickstef11
|
||||
usrnk1
|
||||
vimtor
|
||||
StarpTech
|
||||
starptech
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -95,7 +95,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -143,7 +143,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -157,7 +157,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -193,7 +193,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -220,7 +220,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -242,7 +242,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -266,7 +266,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -286,7 +286,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"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.88",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
@@ -303,7 +303,7 @@
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.84",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
@@ -338,7 +338,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.11.1",
|
||||
"gitlab-ai-provider": "6.10.0",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -380,7 +380,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
@@ -434,7 +434,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -448,7 +448,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -460,7 +460,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -492,7 +492,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -508,7 +508,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
@@ -539,7 +539,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
@@ -558,7 +558,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"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.88",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.60",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
@@ -578,7 +578,7 @@
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.84",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
@@ -631,7 +631,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.11.1",
|
||||
"gitlab-ai-provider": "6.10.0",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -689,7 +689,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -765,7 +765,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -780,7 +780,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -795,7 +795,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -839,7 +839,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -852,10 +852,9 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solidjs/meta": "catalog:",
|
||||
@@ -886,7 +885,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -905,7 +904,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -946,7 +945,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -973,7 +972,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -1024,7 +1023,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1067,7 +1066,6 @@
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
@@ -1075,8 +1073,10 @@
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
},
|
||||
"overrides": {
|
||||
@@ -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.32",
|
||||
"@tanstack/solid-virtual": "3.13.28",
|
||||
"@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.6",
|
||||
"marked": "17.0.1",
|
||||
"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.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="],
|
||||
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
|
||||
|
||||
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="],
|
||||
|
||||
@@ -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.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="],
|
||||
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
|
||||
|
||||
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="],
|
||||
|
||||
@@ -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.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="],
|
||||
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="],
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="],
|
||||
|
||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
||||
|
||||
@@ -3850,7 +3850,7 @@
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.11.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="],
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.10.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="],
|
||||
|
||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||
|
||||
@@ -4312,7 +4312,7 @@
|
||||
|
||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||
|
||||
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
|
||||
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
|
||||
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
|
||||
|
||||
@@ -5654,11 +5654,7 @@
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
@@ -5670,9 +5666,9 @@
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
@@ -6024,7 +6020,7 @@
|
||||
|
||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
@@ -6050,8 +6046,6 @@
|
||||
|
||||
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||
|
||||
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
|
||||
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||
|
||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||
@@ -6168,10 +6162,6 @@
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/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=="],
|
||||
@@ -6398,7 +6388,7 @@
|
||||
|
||||
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
@@ -6906,10 +6896,6 @@
|
||||
|
||||
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
|
||||
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
|
||||
|
||||
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||
@@ -6980,14 +6966,6 @@
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/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=="],
|
||||
@@ -7140,10 +7118,6 @@
|
||||
|
||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
|
||||
|
||||
"opencode/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
@@ -7392,8 +7366,6 @@
|
||||
|
||||
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||
|
||||
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
||||
@@ -7410,10 +7382,6 @@
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/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=="],
|
||||
@@ -7484,8 +7452,6 @@
|
||||
|
||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=",
|
||||
"aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=",
|
||||
"aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=",
|
||||
"x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw="
|
||||
"x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=",
|
||||
"aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=",
|
||||
"aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=",
|
||||
"x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk="
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -43,7 +43,7 @@
|
||||
"@opentui/core": "0.4.3",
|
||||
"@opentui/keymap": "0.4.3",
|
||||
"@opentui/solid": "0.4.3",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@tanstack/solid-virtual": "3.13.28",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
@@ -70,7 +70,7 @@
|
||||
"hono-openapi": "1.1.2",
|
||||
"fuzzysort": "3.1.0",
|
||||
"luxon": "3.6.1",
|
||||
"marked": "17.0.6",
|
||||
"marked": "17.0.1",
|
||||
"marked-shiki": "1.2.1",
|
||||
"remend": "1.3.0",
|
||||
"@playwright/test": "1.59.1",
|
||||
@@ -152,9 +152,10 @@
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,9 +136,6 @@ export async function setupTimeline(
|
||||
},
|
||||
}),
|
||||
)
|
||||
if (settings.newLayoutDesigns === false) {
|
||||
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
||||
}
|
||||
}, input.settings ?? {})
|
||||
if (input.locale) {
|
||||
await page.addInitScript((locale) => {
|
||||
|
||||
@@ -24,7 +24,6 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
const directoryA = "C:/server-a"
|
||||
const directoryB = "/home/server-b"
|
||||
const sessionA = session("ses_server_a", directoryA, "Server A session")
|
||||
const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
|
||||
const sessionB = session("ses_server_b", directoryB, "Server B session")
|
||||
|
||||
test("session settings use the remote server context", async ({ page }) => {
|
||||
const permissionRequests: string[] = []
|
||||
await mockServers(page, permissionRequests)
|
||||
await configureServers(page)
|
||||
|
||||
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
|
||||
|
||||
const dialog = page.locator(".settings-v2-dialog")
|
||||
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
|
||||
const input = autoAccept.getByRole("switch")
|
||||
await expect(autoAccept).toBeVisible()
|
||||
await expect(input).toBeEnabled()
|
||||
permissionRequests.length = 0
|
||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||
await expect(input).toBeChecked()
|
||||
await expect
|
||||
.poll(() =>
|
||||
permissionRequests.some((request) => {
|
||||
const url = new URL(request)
|
||||
return url.origin === serverB && url.searchParams.get("directory") === directoryB
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
|
||||
|
||||
await dialog.getByRole("tab", { name: "Models" }).click()
|
||||
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
|
||||
await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("auto-accept responds for an unfocused server session", async ({ page }) => {
|
||||
const permissionRequests: string[] = []
|
||||
const permissionResponses: PermissionResponse[] = []
|
||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
||||
server: serverA,
|
||||
retry: 20,
|
||||
})
|
||||
await mockServers(page, permissionRequests, permissionResponses)
|
||||
await configureServers(page, [
|
||||
{ type: "session", server: serverA, sessionId: sessionA.id },
|
||||
{ type: "session", server: serverB, sessionId: sessionB.id },
|
||||
])
|
||||
|
||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
||||
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
|
||||
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||
await expect
|
||||
.poll(() =>
|
||||
permissionRequests.some((request) => {
|
||||
const url = new URL(request)
|
||||
return url.origin === serverA && url.searchParams.get("directory") === directoryA
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
await transport.waitForConnection()
|
||||
|
||||
await transport.send({
|
||||
directory: directoryA,
|
||||
payload: {
|
||||
id: "event-permission-background-a",
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "permission-background-a",
|
||||
sessionID: sessionA.id,
|
||||
permission: "bash",
|
||||
patterns: ["git status"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => permissionResponses)
|
||||
.toEqual([
|
||||
{
|
||||
origin: serverA,
|
||||
directory: directoryA,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-background-a",
|
||||
body: { response: "once" },
|
||||
},
|
||||
])
|
||||
|
||||
await transport.send({
|
||||
directory: directoryA,
|
||||
payload: {
|
||||
id: "event-permission-background-a-child",
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "permission-background-a-child",
|
||||
sessionID: childSessionA.id,
|
||||
permission: "bash",
|
||||
patterns: ["git diff"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => permissionResponses)
|
||||
.toEqual([
|
||||
{
|
||||
origin: serverA,
|
||||
directory: directoryA,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-background-a",
|
||||
body: { response: "once" },
|
||||
},
|
||||
{
|
||||
origin: serverA,
|
||||
directory: directoryA,
|
||||
sessionID: childSessionA.id,
|
||||
permissionID: "permission-background-a-child",
|
||||
body: { response: "once" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
type PermissionResponse = {
|
||||
origin: string
|
||||
directory?: string
|
||||
sessionID: string
|
||||
permissionID: string
|
||||
body: unknown
|
||||
}
|
||||
|
||||
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
|
||||
await page.addInitScript(
|
||||
({ serverB, tabs }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
|
||||
},
|
||||
{ serverB, tabs },
|
||||
)
|
||||
}
|
||||
|
||||
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
const remote = url.origin === serverB
|
||||
const directory = remote ? directoryB : directoryA
|
||||
const sessions = remote ? [sessionB] : [sessionA, childSessionA]
|
||||
const requestDirectory = url.searchParams.get("directory")
|
||||
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
|
||||
if (route.request().method() === "POST" && response) {
|
||||
permissionResponses.push({
|
||||
origin: url.origin,
|
||||
directory: requestDirectory ?? undefined,
|
||||
sessionID: response[1]!,
|
||||
permissionID: response[2]!,
|
||||
body: route.request().postDataJSON(),
|
||||
})
|
||||
return json(route, true)
|
||||
}
|
||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/session/status") return json(route, {})
|
||||
if (url.pathname === "/session") return json(route, sessions)
|
||||
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
||||
if (current) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (url.pathname === "/permission") {
|
||||
permissionRequests.push(url.toString())
|
||||
return json(route, [])
|
||||
}
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
const project = {
|
||||
id: remote ? sessionB.projectID : "project-server-a",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/project" ? [project] : project)
|
||||
}
|
||||
if (url.pathname === "/path")
|
||||
return json(route, {
|
||||
state: directory,
|
||||
config: directory,
|
||||
worktree: directory,
|
||||
directory,
|
||||
home: directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
function session(id: string, directory: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID: `project-${id}`,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
function provider(id: string) {
|
||||
const name = id === "server-b" ? "Server B" : "Server A"
|
||||
return {
|
||||
all: [
|
||||
{
|
||||
id,
|
||||
name: `${name} Provider`,
|
||||
models: {
|
||||
[id]: {
|
||||
id,
|
||||
name: `${name} Model`,
|
||||
family: id,
|
||||
release_date: "2026-01-01",
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: [id],
|
||||
default: { providerID: id, modelID: id },
|
||||
}
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/RequestDocks"
|
||||
@@ -101,67 +100,6 @@ test("shows a pending permission dock", async ({ page }) => {
|
||||
expect(request.postDataJSON()).toEqual({ response: "once" })
|
||||
})
|
||||
|
||||
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||
const transport = await installSseTransport(page, {
|
||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||
retry: 20,
|
||||
})
|
||||
await mockServer(page, { questions: [] })
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
||||
const draft = "keep the caret at the end"
|
||||
await editor.fill(draft)
|
||||
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
|
||||
for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft")
|
||||
const cursor = draft.length - 4
|
||||
await expect
|
||||
.poll(() =>
|
||||
editor.evaluate((element) => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1
|
||||
const range = selection.getRangeAt(0).cloneRange()
|
||||
range.selectNodeContents(element)
|
||||
range.setEnd(selection.anchorNode!, selection.anchorOffset)
|
||||
return range.toString().length
|
||||
}),
|
||||
)
|
||||
.toBe(cursor)
|
||||
await transport.send({
|
||||
directory,
|
||||
payload: {
|
||||
type: "question.asked",
|
||||
properties: {
|
||||
id: "question-caret",
|
||||
sessionID,
|
||||
questions: [
|
||||
{
|
||||
header: "Continue",
|
||||
question: "Continue?",
|
||||
options: [{ label: "Yes", description: "Continue the session" }],
|
||||
},
|
||||
],
|
||||
tool: { messageID: "message-caret", callID: "call-caret" },
|
||||
},
|
||||
},
|
||||
})
|
||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
await expect(question).toBeVisible()
|
||||
await expect(editor).toHaveCount(0)
|
||||
|
||||
await transport.send({
|
||||
directory,
|
||||
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
||||
})
|
||||
await expect(question).toHaveCount(0)
|
||||
await expect(editor).toBeVisible()
|
||||
await page.keyboard.press("x")
|
||||
|
||||
await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`)
|
||||
})
|
||||
|
||||
async function mockServer(
|
||||
page: Page,
|
||||
requests: {
|
||||
|
||||
@@ -17,14 +17,12 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const initialPageSize = 20
|
||||
const historyPageSize = 200
|
||||
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
|
||||
const assistants = Array.from({ length: 14 }, (_, index) =>
|
||||
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||
parentID: userID,
|
||||
created: 1700000001000 + index * 1_000,
|
||||
completed: index < initialPageSize,
|
||||
completed: index < 13,
|
||||
}),
|
||||
)
|
||||
const messages = [userMessage(), ...assistants]
|
||||
@@ -48,7 +46,7 @@ const scenarios = [
|
||||
test.use({ viewport: { width: 646, height: 1385 } })
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => {
|
||||
test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => {
|
||||
const requests: { before?: string; phase: "start" | "end" }[] = []
|
||||
const pages: { before?: string; limit: number }[] = []
|
||||
const roots: { sessionID: string; messageID: string }[] = []
|
||||
@@ -103,51 +101,36 @@ for (const scenario of scenarios) {
|
||||
}
|
||||
},
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
const visibleParts = () => {
|
||||
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||
const view = viewport?.getBoundingClientRect()
|
||||
if (!viewport || !view) return []
|
||||
return [...viewport.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
|
||||
.filter((part) => {
|
||||
const rect = part.getBoundingClientRect()
|
||||
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
.flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : []))
|
||||
}
|
||||
const state = {
|
||||
armed: false,
|
||||
hidden: false,
|
||||
visibleParts: [] as string[],
|
||||
samples: 0,
|
||||
stop: false,
|
||||
arm() {
|
||||
state.visibleParts = visibleParts()
|
||||
state.armed = true
|
||||
},
|
||||
}
|
||||
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
|
||||
const sample = () => {
|
||||
if (state.armed) {
|
||||
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||
const view = viewport?.getBoundingClientRect()
|
||||
const visible = (partID: string) => {
|
||||
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(partID)}"]`)
|
||||
const rect = part?.getBoundingClientRect()
|
||||
return (
|
||||
!!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ userPartID, lastPartID }) => {
|
||||
const state = { armed: false, hidden: false, samples: 0, stop: false }
|
||||
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
|
||||
const sample = () => {
|
||||
if (state.armed) {
|
||||
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||
const view = viewport?.getBoundingClientRect()
|
||||
const visible = (partID: string) => {
|
||||
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
|
||||
const rect = part?.getBoundingClientRect()
|
||||
return (
|
||||
!!rect &&
|
||||
!!view &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.bottom > view.top &&
|
||||
rect.top < view.bottom
|
||||
)
|
||||
}
|
||||
if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true
|
||||
state.samples++
|
||||
}
|
||||
if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID)))
|
||||
state.hidden = true
|
||||
state.samples++
|
||||
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
|
||||
}
|
||||
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
|
||||
}
|
||||
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||
})
|
||||
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||
},
|
||||
{ userPartID, lastPartID },
|
||||
)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
@@ -160,28 +143,23 @@ for (const scenario of scenarios) {
|
||||
"messages:start:latest",
|
||||
"messages:end:latest",
|
||||
`message:${userID}`,
|
||||
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
|
||||
`messages:start:${messages.at(-2)!.info.id}`,
|
||||
])
|
||||
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
|
||||
await page.evaluate(() => {
|
||||
;(
|
||||
window as Window & {
|
||||
__historyRootProbe?: { arm(): void }
|
||||
__historyRootProbe?: { armed: boolean }
|
||||
}
|
||||
).__historyRootProbe!.arm()
|
||||
).__historyRootProbe!.armed = true
|
||||
})
|
||||
await waitForProbeSamples(page, 0)
|
||||
expect(await visibleContentHidden(page)).toBe(false)
|
||||
expect(await historyRootHidden(page)).toBe(false)
|
||||
const beforeHistory = await probeSamples(page)
|
||||
history.resolve()
|
||||
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
|
||||
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
|
||||
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14)
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
await waitForProbeSamples(page, beforeHistory)
|
||||
expect(pages).toEqual([
|
||||
{ before: undefined, limit: initialPageSize },
|
||||
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
|
||||
])
|
||||
expect(pages[0]).toEqual({ before: undefined, limit: 2 })
|
||||
expect(roots).toEqual([{ sessionID, messageID: userID }])
|
||||
|
||||
const message = messageUpdated(scenario.info)
|
||||
@@ -235,7 +213,7 @@ async function waitForProbeSamples(page: Page, after: number) {
|
||||
)
|
||||
}
|
||||
|
||||
function visibleContentHidden(page: Page) {
|
||||
function historyRootHidden(page: Page) {
|
||||
return page.evaluate(
|
||||
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -7,11 +7,10 @@ const directory = "C:/OpenCode/TerminalComposerFocus"
|
||||
const projectID = "proj_terminal_composer_focus"
|
||||
const sessionID = "ses_terminal_composer_focus"
|
||||
const ptyID = "pty_terminal_composer_focus"
|
||||
const newPtyID = "pty_terminal_composer_focus_new"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -68,9 +67,7 @@ test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
})
|
||||
})
|
||||
|
||||
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
@@ -90,120 +87,3 @@ test("routes typing to the composer unless the open terminal is focused", async
|
||||
await expect(composer).toBeFocused()
|
||||
await expect(composer).toHaveText("a")
|
||||
})
|
||||
|
||||
test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => {
|
||||
const ghostty = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const created = { count: 0 }
|
||||
await page.route("**/pty", (route) => {
|
||||
created.count += 1
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||
})
|
||||
})
|
||||
await page.route(/ghostty-web/, async (route) => {
|
||||
ghostty.resolve()
|
||||
await release.promise
|
||||
await route.continue()
|
||||
})
|
||||
await seedCachedTerminal(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await expect(terminal).toBeVisible()
|
||||
expect(created.count).toBe(0)
|
||||
await ghostty.promise
|
||||
await composer.click()
|
||||
await expect(composer).toBeFocused()
|
||||
|
||||
release.resolve()
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
await page.waitForTimeout(300)
|
||||
await expect(composer).toBeFocused()
|
||||
})
|
||||
|
||||
test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => {
|
||||
const ghostty = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(/ghostty-web/, async (route) => {
|
||||
ghostty.resolve()
|
||||
await release.promise
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeVisible()
|
||||
await ghostty.promise
|
||||
await composer.click()
|
||||
await expect(composer).toBeFocused()
|
||||
|
||||
release.resolve()
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
await page.waitForTimeout(50)
|
||||
await expect(composer).toBeFocused()
|
||||
})
|
||||
|
||||
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
|
||||
const created = { count: 0 }
|
||||
await page.route("**/pty", (route) => {
|
||||
created.count += 1
|
||||
const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(next),
|
||||
})
|
||||
})
|
||||
await page.route(`**/pty/${newPtyID}`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
)
|
||||
await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
await composer.click()
|
||||
await expect(composer).toBeFocused()
|
||||
|
||||
await page.getByRole("button", { name: "New terminal" }).click()
|
||||
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
|
||||
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
})
|
||||
|
||||
function seedCachedTerminal(page: Page) {
|
||||
return page.addInitScript(
|
||||
({ terminalKey, ptyID }) => {
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
|
||||
localStorage.setItem(
|
||||
terminalKey,
|
||||
JSON.stringify({
|
||||
active: ptyID,
|
||||
all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }],
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Timeline Suspense Reproduction</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #171717;
|
||||
color: #f5f5f5;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
#root {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="root"></main>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,315 +0,0 @@
|
||||
import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
|
||||
import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
|
||||
|
||||
const rowCount = 2_000
|
||||
const rowHeight = 40
|
||||
const parameters = new URLSearchParams(location.search)
|
||||
const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline"
|
||||
const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline"
|
||||
|
||||
type MutationEvent = {
|
||||
kind: "removed" | "added"
|
||||
callbackTime: number
|
||||
callbackFrame: number
|
||||
routeConnectedInCallback: boolean
|
||||
nativeOffsetInCallback: number
|
||||
}
|
||||
|
||||
type Snapshot = {
|
||||
mode: {
|
||||
resource: "baseline" | "guard"
|
||||
reconnect: "baseline" | "candidate"
|
||||
}
|
||||
operation: {
|
||||
sequence: number
|
||||
phase: string
|
||||
time: number
|
||||
frame: number
|
||||
}
|
||||
resourceState: string
|
||||
routeConnected: boolean
|
||||
viewportConnected: boolean
|
||||
viewportOwnedByRoute: boolean
|
||||
sameRoute: boolean
|
||||
sameViewport: boolean
|
||||
sameSurface: boolean
|
||||
sameMountedRows: boolean
|
||||
nativeOffset: number
|
||||
coreOffset: number
|
||||
rangeStart: number
|
||||
rangeEnd: number
|
||||
indexes: number[]
|
||||
domIndexes: number[]
|
||||
logicalSurfaceHeight: number
|
||||
renderedSurfaceHeight: number
|
||||
viewportClientHeight: number
|
||||
viewportScrollHeight: number
|
||||
visibleRows: number
|
||||
minimumRowTop: number
|
||||
domScrollEvents: number
|
||||
lastScrollTrusted: boolean
|
||||
coreOffsetCallbackCalls: number
|
||||
offsetCallbackSources: "observer"[]
|
||||
rectObserverCallbacks: number
|
||||
ignoredDetachedZeroRects: number
|
||||
syntheticScrollDispatches: number
|
||||
mutationEvents: MutationEvent[]
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
timelineSuspense: {
|
||||
prepare: () => Promise<Snapshot>
|
||||
trigger: () => void
|
||||
resolve: () => void
|
||||
frames: (count?: number) => Promise<void>
|
||||
snapshot: () => Snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [refresh, setRefresh] = createSignal(false)
|
||||
let resolveResource: (() => void) | undefined
|
||||
const [resource] = createResource(
|
||||
refresh,
|
||||
(version) =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveResource = () => resolve(`settled-${version}`)
|
||||
}),
|
||||
{ initialValue: "settled" },
|
||||
)
|
||||
|
||||
function Route() {
|
||||
let route: HTMLElement | undefined
|
||||
let viewport: HTMLDivElement | undefined
|
||||
let surface: HTMLDivElement | undefined
|
||||
let initialRoute: HTMLElement | undefined
|
||||
let initialViewport: HTMLDivElement | undefined
|
||||
let initialSurface: HTMLDivElement | undefined
|
||||
let initialRows: HTMLElement[] = []
|
||||
let phase = "mounting"
|
||||
let browserFrame = 0
|
||||
let snapshotSequence = 0
|
||||
let domScrollEvents = 0
|
||||
let lastScrollTrusted = false
|
||||
let coreOffsetCallbackCalls = 0
|
||||
let rectObserverCallbacks = 0
|
||||
let ignoredDetachedZeroRects = 0
|
||||
const offsetCallbackSources: "observer"[] = []
|
||||
const mutationEvents: MutationEvent[] = []
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
count: rowCount,
|
||||
getScrollElement: () => viewport ?? null,
|
||||
estimateSize: () => rowHeight,
|
||||
initialRect: { width: 900, height: 600 },
|
||||
overscan: 2,
|
||||
observeElementRect: (instance, callback) =>
|
||||
observeElementRect(instance, (rect) => {
|
||||
rectObserverCallbacks++
|
||||
// A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect.
|
||||
if (!instance.scrollElement?.isConnected && rect.height === 0) {
|
||||
ignoredDetachedZeroRects++
|
||||
return
|
||||
}
|
||||
callback(rect)
|
||||
}),
|
||||
observeElementOffset: (instance, callback) => {
|
||||
const deliver = (offset: number, isScrolling: boolean) => {
|
||||
coreOffsetCallbackCalls++
|
||||
offsetCallbackSources.push("observer")
|
||||
callback(offset, isScrolling)
|
||||
}
|
||||
if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver)
|
||||
return observeElementOffset(instance, deliver)
|
||||
},
|
||||
})
|
||||
|
||||
const frames = async (count = 2) => {
|
||||
for (let index = 0; index < count; index++) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
}
|
||||
const mountedRows = () => [...(surface?.querySelectorAll<HTMLElement>("[data-row-index]") ?? [])]
|
||||
const snapshot = (): Snapshot => {
|
||||
const rows = mountedRows()
|
||||
const view = viewport?.getBoundingClientRect()
|
||||
const visibleRows =
|
||||
viewport?.isConnected && view
|
||||
? rows.filter((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.bottom > view.top && rect.top < view.bottom
|
||||
}).length
|
||||
: 0
|
||||
return {
|
||||
mode: { resource: resourceMode, reconnect: reconnectMode },
|
||||
operation: {
|
||||
sequence: ++snapshotSequence,
|
||||
phase,
|
||||
time: performance.now(),
|
||||
frame: browserFrame,
|
||||
},
|
||||
resourceState: resource.state,
|
||||
routeConnected: route?.isConnected ?? false,
|
||||
viewportConnected: viewport?.isConnected ?? false,
|
||||
viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport),
|
||||
sameRoute: route === initialRoute,
|
||||
sameViewport: viewport === initialViewport,
|
||||
sameSurface: surface === initialSurface,
|
||||
sameMountedRows:
|
||||
initialRows.length > 0 &&
|
||||
initialRows.length === rows.length &&
|
||||
initialRows.every((row, index) => row === rows[index]),
|
||||
nativeOffset: viewport?.scrollTop ?? -1,
|
||||
coreOffset: virtualizer.scrollOffset ?? -1,
|
||||
rangeStart: virtualizer.range?.startIndex ?? -1,
|
||||
rangeEnd: virtualizer.range?.endIndex ?? -1,
|
||||
indexes: virtualizer.getVirtualItems().map((item) => item.index),
|
||||
domIndexes: rows.map((row) => Number(row.dataset.rowIndex)),
|
||||
logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"),
|
||||
renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1,
|
||||
viewportClientHeight: viewport?.clientHeight ?? -1,
|
||||
viewportScrollHeight: viewport?.scrollHeight ?? -1,
|
||||
visibleRows,
|
||||
minimumRowTop:
|
||||
rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1,
|
||||
domScrollEvents,
|
||||
lastScrollTrusted,
|
||||
coreOffsetCallbackCalls,
|
||||
offsetCallbackSources: [...offsetCallbackSources],
|
||||
rectObserverCallbacks,
|
||||
ignoredDetachedZeroRects,
|
||||
syntheticScrollDispatches: 0,
|
||||
mutationEvents: mutationEvents.map((event) => ({ ...event })),
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount")
|
||||
const routeRoot = route.parentElement
|
||||
if (!routeRoot) throw new Error("Timeline route root did not mount")
|
||||
initialRoute = route
|
||||
initialViewport = viewport
|
||||
initialSurface = surface
|
||||
viewport.addEventListener("scroll", (event) => {
|
||||
domScrollEvents++
|
||||
lastScrollTrusted = event.isTrusted
|
||||
})
|
||||
const countFrames = () => {
|
||||
browserFrame++
|
||||
requestAnimationFrame(countFrames)
|
||||
}
|
||||
requestAnimationFrame(countFrames)
|
||||
new MutationObserver((records) => {
|
||||
const callbackTime = performance.now()
|
||||
records.forEach((record) => {
|
||||
;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => {
|
||||
if (node !== route) return
|
||||
phase = "detached"
|
||||
mutationEvents.push({
|
||||
kind: "removed",
|
||||
callbackTime,
|
||||
callbackFrame: browserFrame,
|
||||
routeConnectedInCallback: route.isConnected,
|
||||
nativeOffsetInCallback: viewport.scrollTop,
|
||||
})
|
||||
})
|
||||
;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => {
|
||||
if (node !== route) return
|
||||
phase = "reinserted"
|
||||
mutationEvents.push({
|
||||
kind: "added",
|
||||
callbackTime,
|
||||
callbackFrame: browserFrame,
|
||||
routeConnectedInCallback: route.isConnected,
|
||||
nativeOffsetInCallback: viewport.scrollTop,
|
||||
})
|
||||
})
|
||||
})
|
||||
}).observe(routeRoot, { childList: true })
|
||||
window.timelineSuspense = {
|
||||
prepare: async () => {
|
||||
phase = "preparing"
|
||||
await frames(2)
|
||||
viewport.scrollTop = viewport.scrollHeight
|
||||
await frames(3)
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
await frames(2)
|
||||
initialRows = mountedRows()
|
||||
phase = "prepared"
|
||||
return snapshot()
|
||||
},
|
||||
trigger: () => {
|
||||
phase = "triggering"
|
||||
setRefresh(true)
|
||||
},
|
||||
resolve: () => {
|
||||
if (!resolveResource) throw new Error("Resource is not pending")
|
||||
phase = "resolving"
|
||||
resolveResource()
|
||||
},
|
||||
frames,
|
||||
snapshot,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<section ref={route} data-route style={{ width: "900px", margin: "0 auto" }}>
|
||||
<span aria-hidden="true" style={{ display: "none" }}>
|
||||
{resourceMode === "guard" && resource.state === "refreshing" ? resource.latest : resource()}
|
||||
</span>
|
||||
<div
|
||||
ref={viewport}
|
||||
data-viewport
|
||||
style={{
|
||||
height: "600px",
|
||||
overflow: "auto",
|
||||
"overflow-anchor": "none",
|
||||
position: "relative",
|
||||
background: "#202020",
|
||||
outline: "1px solid #3f3f46",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={surface}
|
||||
data-surface
|
||||
style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", "overflow-anchor": "none" }}
|
||||
>
|
||||
<For each={virtualizer.getVirtualItems()}>
|
||||
{(item) => (
|
||||
<div
|
||||
data-row-index={item.index}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: `${item.size}px`,
|
||||
transform: `translateY(${item.start}px)`,
|
||||
padding: "10px 14px",
|
||||
border: "0 solid #333",
|
||||
"border-bottom-width": "1px",
|
||||
}}
|
||||
>
|
||||
logical row {item.index}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Suspense>
|
||||
<Route />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
render(() => <App />, document.getElementById("root")!)
|
||||
@@ -1,34 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317)
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "timeline-suspense.repro.ts",
|
||||
outputDir: "../../test-results/timeline-suspense",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: "line",
|
||||
timeout: 30_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
webServer: {
|
||||
command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`,
|
||||
cwd: import.meta.dirname,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${port}`,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -1,179 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("pageerror", (error) => console.error(error))
|
||||
await page.goto("/")
|
||||
await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true)
|
||||
})
|
||||
|
||||
test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => {
|
||||
await page.goto("/?reconnect=candidate")
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate")
|
||||
const before = await prepare(page)
|
||||
await triggerBaselineSuspension(page)
|
||||
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
expect(pending.nativeOffset).toBe(0)
|
||||
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||
expect(pending.indexes).toEqual(before.indexes)
|
||||
expect(pending.sameRoute).toBe(true)
|
||||
expect(pending.sameViewport).toBe(true)
|
||||
expect(pending.sameSurface).toBe(true)
|
||||
expect(pending.sameMountedRows).toBe(true)
|
||||
|
||||
await resolveSuspension(page)
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0)
|
||||
await page.waitForTimeout(250)
|
||||
await page.evaluate(() => window.timelineSuspense.frames(2))
|
||||
const after = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
expect(after.sameRoute).toBe(true)
|
||||
expect(after.sameViewport).toBe(true)
|
||||
expect(after.sameSurface).toBe(true)
|
||||
expect(after.nativeOffset).toBe(0)
|
||||
expect(after.coreOffset).toBe(0)
|
||||
expect(after.rangeStart).toBeLessThan(10)
|
||||
expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0)
|
||||
expect(after.domScrollEvents).toBe(before.domScrollEvents)
|
||||
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1)
|
||||
expect(after.offsetCallbackSources.at(-1)).toBe("observer")
|
||||
expect(after.syntheticScrollDispatches).toBe(0)
|
||||
})
|
||||
|
||||
test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({
|
||||
page,
|
||||
}) => {
|
||||
const before = await prepare(page)
|
||||
const beforeRows = before.domIndexes
|
||||
expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" })
|
||||
expect(before.logicalSurfaceHeight).toBe(80_000)
|
||||
expect(before.renderedSurfaceHeight).toBe(80_000)
|
||||
expect(before.viewportClientHeight).toBe(600)
|
||||
expect(before.viewportScrollHeight).toBe(80_000)
|
||||
expect(before.rangeStart).toBeGreaterThan(1_900)
|
||||
expect(before.nativeOffset).toBe(before.coreOffset)
|
||||
expect(before.visibleRows).toBeGreaterThan(0)
|
||||
|
||||
await triggerBaselineSuspension(page)
|
||||
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
expect(pending.resourceState).toBe("refreshing")
|
||||
expect(pending.routeConnected).toBe(false)
|
||||
expect(pending.viewportConnected).toBe(false)
|
||||
expect(pending.viewportOwnedByRoute).toBe(true)
|
||||
expect(pending.nativeOffset).toBe(0)
|
||||
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||
expect(pending.rangeStart).toBe(before.rangeStart)
|
||||
expect(pending.rangeEnd).toBe(before.rangeEnd)
|
||||
expect(pending.indexes).toEqual(before.indexes)
|
||||
expect(pending.domIndexes).toEqual(beforeRows)
|
||||
expect(pending.sameMountedRows).toBe(true)
|
||||
expect(pending.domScrollEvents).toBe(before.domScrollEvents)
|
||||
expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
|
||||
expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects)
|
||||
expect(pending.mutationEvents).toHaveLength(1)
|
||||
expect(pending.mutationEvents[0]).toMatchObject({
|
||||
kind: "removed",
|
||||
routeConnectedInCallback: false,
|
||||
nativeOffsetInCallback: 0,
|
||||
})
|
||||
expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time)
|
||||
expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame)
|
||||
|
||||
const after = await resolveSuspension(page)
|
||||
expect(after.resourceState).toBe("ready")
|
||||
expect(after.routeConnected).toBe(true)
|
||||
expect(after.viewportConnected).toBe(true)
|
||||
expect(after.viewportOwnedByRoute).toBe(true)
|
||||
expect(after.sameRoute).toBe(true)
|
||||
expect(after.sameViewport).toBe(true)
|
||||
expect(after.sameSurface).toBe(true)
|
||||
expect(after.sameMountedRows).toBe(true)
|
||||
expect(after.nativeOffset).toBe(0)
|
||||
expect(after.coreOffset).toBe(before.coreOffset)
|
||||
expect(after.rangeStart).toBe(before.rangeStart)
|
||||
expect(after.rangeEnd).toBe(before.rangeEnd)
|
||||
expect(after.indexes).toEqual(before.indexes)
|
||||
expect(after.domIndexes).toEqual(beforeRows)
|
||||
expect(after.domScrollEvents).toBe(before.domScrollEvents)
|
||||
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
|
||||
expect(after.mutationEvents).toHaveLength(2)
|
||||
expect(after.mutationEvents[1]).toMatchObject({
|
||||
kind: "added",
|
||||
routeConnectedInCallback: true,
|
||||
nativeOffsetInCallback: 0,
|
||||
})
|
||||
expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time)
|
||||
expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame)
|
||||
expect(after.visibleRows).toBe(0)
|
||||
expect(after.minimumRowTop).toBeGreaterThan(50_000)
|
||||
expect(after.syntheticScrollDispatches).toBe(0)
|
||||
|
||||
await page.locator("[data-viewport]").hover()
|
||||
await page.mouse.wheel(0, 80)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const value = window.timelineSuspense.snapshot()
|
||||
return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await page.evaluate(() => window.timelineSuspense.frames(2))
|
||||
const recovered = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents)
|
||||
expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls)
|
||||
expect(recovered.offsetCallbackSources.at(-1)).toBe("observer")
|
||||
expect(recovered.lastScrollTrusted).toBe(true)
|
||||
expect(recovered.rangeStart).toBeLessThan(10)
|
||||
expect(recovered.visibleRows).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => {
|
||||
await page.goto("/?resource=guard")
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard")
|
||||
const before = await prepare(page)
|
||||
|
||||
await page.evaluate(() => window.timelineSuspense.trigger())
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
|
||||
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
expect(pending.routeConnected).toBe(true)
|
||||
expect(pending.mutationEvents).toEqual([])
|
||||
expect(pending.nativeOffset).toBe(before.nativeOffset)
|
||||
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||
expect(pending.visibleRows).toBeGreaterThan(0)
|
||||
|
||||
const after = await resolveSuspension(page)
|
||||
expect(after.routeConnected).toBe(true)
|
||||
expect(after.nativeOffset).toBe(before.nativeOffset)
|
||||
expect(after.coreOffset).toBe(before.coreOffset)
|
||||
expect(after.visibleRows).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
async function prepare(page: Page) {
|
||||
const before = await page.evaluate(() => window.timelineSuspense.prepare())
|
||||
expect(before.routeConnected).toBe(true)
|
||||
expect(before.viewportConnected).toBe(true)
|
||||
expect(before.viewportOwnedByRoute).toBe(true)
|
||||
expect(before.sameMountedRows).toBe(true)
|
||||
expect(before.rangeStart).toBeGreaterThan(1_900)
|
||||
expect(before.nativeOffset).toBe(before.coreOffset)
|
||||
return before
|
||||
}
|
||||
|
||||
async function triggerBaselineSuspension(page: Page) {
|
||||
await page.evaluate(() => window.timelineSuspense.trigger())
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false)
|
||||
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||
}
|
||||
|
||||
async function resolveSuspension(page: Page) {
|
||||
await page.evaluate(() => window.timelineSuspense.resolve())
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready")
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true)
|
||||
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||
return page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
}
|
||||
|
||||
function diagnostic(value: unknown) {
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
|
||||
export default defineConfig({
|
||||
root: import.meta.dirname,
|
||||
plugins: [solid()],
|
||||
})
|
||||
@@ -10,9 +10,6 @@
|
||||
"./performance/timeline-stability/fixture.test.ts",
|
||||
"./performance/timeline-stability/fixture.ts",
|
||||
"./performance/unit/visual-stability.test.ts",
|
||||
"./reproduction/timeline-suspense/**/*.ts",
|
||||
"./reproduction/timeline-suspense/**/*.tsx",
|
||||
"../src/pages/session/timeline/observe-element-offset.ts",
|
||||
"./regression/new-session-panel-corner.spec.ts",
|
||||
"./regression/session-timeline-context-resize.spec.ts",
|
||||
"./utils/**/*.ts"
|
||||
|
||||
@@ -56,11 +56,6 @@ 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 ?? []))
|
||||
@@ -137,30 +132,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) {
|
||||
const time = session.time && typeof session.time === "object" ? session.time : {}
|
||||
return {
|
||||
id: session.id,
|
||||
parentID: session.parentID,
|
||||
projectID: session.projectID ?? "project",
|
||||
cost: session.cost ?? 0,
|
||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: {
|
||||
created: "created" in time && typeof time.created === "number" ? time.created : 0,
|
||||
updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
|
||||
...(session.time && typeof session.time === "object" && "archived" in session.time
|
||||
? { archived: session.time.archived }
|
||||
: {}),
|
||||
},
|
||||
title: session.title ?? session.id,
|
||||
location: {
|
||||
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
||||
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
||||
},
|
||||
...(typeof session.path === "string" ? { subpath: session.path } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
|
||||
@@ -162,7 +162,7 @@ export async function installSseTransport<T>(
|
||||
const request = new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
||||
return originalFetch(request)
|
||||
return originalFetch(input, init)
|
||||
|
||||
const id = ++nextConnectionID
|
||||
const record = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
+27
-17
@@ -153,7 +153,8 @@ function LegacyTargetSessionRedirect() {
|
||||
}
|
||||
|
||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||
// Notification/Models + the visual Layout) for that server.
|
||||
function SelectedServerProviders(props: ParentProps) {
|
||||
return (
|
||||
<ServerKey>
|
||||
@@ -206,7 +207,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<ModelsProvider directory={directory}>
|
||||
<DraftServerScopedProviders directory={directory}>
|
||||
<SDKProvider directory={directory}>
|
||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||
<DraftProviders>
|
||||
@@ -214,7 +215,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</ModelsProvider>
|
||||
</DraftServerScopedProviders>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
@@ -308,21 +309,24 @@ function DesktopCommands() {
|
||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: () => string | undefined
|
||||
sessionID?: () => string | undefined
|
||||
serverScoped?: JSX.Element
|
||||
}>
|
||||
|
||||
function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<LayoutProvider>
|
||||
{props.serverScoped}
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</LayoutProvider>
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<LayoutProvider>
|
||||
{props.serverScoped}
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
|
||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
|
||||
<LegacyLayout>{props.children}</LegacyLayout>
|
||||
</ServerScopedProviders>
|
||||
)
|
||||
@@ -338,6 +342,14 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||
)
|
||||
}
|
||||
|
||||
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||
function DraftProviders(props: ParentProps) {
|
||||
@@ -547,15 +559,13 @@ export function AppInterface(props: {
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<PermissionProvider>
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
</PermissionProvider>
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 187 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 163 KiB |
@@ -1,34 +1,77 @@
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
||||
import homeImage from "@/assets/help/home.png"
|
||||
import tabsImage from "@/assets/help/tabs.png"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const helpIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
data-slot="icon-svg"
|
||||
>
|
||||
<path
|
||||
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
|
||||
fill="var(--v2-icon-icon-base)"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const triggerClass =
|
||||
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
|
||||
|
||||
// TODO: wire to changelog / seen-state when available
|
||||
const showPopover = () => true
|
||||
|
||||
export function HelpButton() {
|
||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
||||
|
||||
const platform = usePlatform()
|
||||
|
||||
return (
|
||||
<a
|
||||
href="https://opencode.ai"
|
||||
aria-label="Open the OpenCode website"
|
||||
data-component="icon-button-v2"
|
||||
data-size="large"
|
||||
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
platform.openLink(event.currentTarget.href)
|
||||
}}
|
||||
>
|
||||
{helpIcon}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
// can remove this after the tabs rollout has been out for a while
|
||||
export function TabsInfoPopup() {
|
||||
const settings = useSettings()
|
||||
const platform = usePlatform()
|
||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
||||
|
||||
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
|
||||
// setState({ dismissed: false }) // for testing
|
||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
||||
<Show when={settings.general.shouldDisplayTabsToast()}>
|
||||
<Show when={!state.dismissed}>
|
||||
<div
|
||||
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
||||
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."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss Tabs information"
|
||||
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
||||
onClick={settings.general.dismissTabsToast}
|
||||
onClick={() => setState("dismissed", true)}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
@@ -45,7 +88,7 @@ export function TabsInfoPopup() {
|
||||
type="button"
|
||||
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
||||
onClick={() => {
|
||||
settings.general.dismissTabsToast()
|
||||
setState("dismissed", true)
|
||||
setDrawerOpen(true)
|
||||
}}
|
||||
>
|
||||
@@ -64,7 +107,7 @@ export function TabsInfoPopup() {
|
||||
Introducing Tabs
|
||||
</p>
|
||||
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
||||
Organize your work and active sessions with tabs
|
||||
A faster, more intuitive way to work.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
@@ -73,45 +116,27 @@ export function TabsInfoPopup() {
|
||||
<DrawerContent>
|
||||
<div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
|
||||
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
||||
July 14
|
||||
June 16
|
||||
</p>
|
||||
<Show when={platform.platform !== "desktop" || platform.os !== "windows"}>
|
||||
<DrawerClose
|
||||
as={IconButtonV2}
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
aria-label="Close"
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</Show>
|
||||
<DrawerClose
|
||||
as={IconButtonV2}
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
aria-label="Close"
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</div>
|
||||
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
|
||||
<div class="relative flex w-full flex-col items-start gap-6 p-8">
|
||||
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
||||
Introducing Tabs
|
||||
Introducing Tabs Navigation.
|
||||
</p>
|
||||
<p class="w-full flex-1 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||
We've introduced tabs as the primary navigation in OpenCode. Your most important session are now pinned at
|
||||
the top of your screen at all times. No more hunting through menus or losing your place mid-session. Switch
|
||||
contexts instantly, pick up exactly where you left off, and keep your focus where it belongs: on the
|
||||
sessions.
|
||||
</p>
|
||||
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||
<p>OpenCode Desktop is now built around tabs.</p>
|
||||
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||
<p>
|
||||
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
|
||||
you're starting something new, and close it when you're done.
|
||||
</p>
|
||||
<p>
|
||||
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
|
||||
memorable if you plan to keep them around.
|
||||
</p>
|
||||
<p>
|
||||
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
|
||||
</p>
|
||||
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||
<p>When you reopen the app, your tabs are still open.</p>
|
||||
<p>
|
||||
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
|
||||
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
|
||||
will become permanent in a few weeks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -633,9 +633,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
||||
|
||||
const handleBlur = () => {
|
||||
const cursor = currentCursor()
|
||||
savedCursor = cursor
|
||||
if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor)
|
||||
savedCursor = currentCursor()
|
||||
closePopover()
|
||||
setComposing(false)
|
||||
}
|
||||
@@ -2203,7 +2201,10 @@ type ComposerModelControlState = {
|
||||
|
||||
function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||
return (
|
||||
<div>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Icon name="sliders" size="small" />
|
||||
</div>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
@@ -2214,32 +2215,17 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuV2 gutter={6} modal={false} placement="top-start">
|
||||
<MenuV2.Trigger
|
||||
as={ButtonV2}
|
||||
data-action="prompt-agent"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="max-w-[175px] justify-start ![font-weight:440]"
|
||||
style={props.state.style}
|
||||
>
|
||||
<span class="truncate capitalize leading-5">{props.state.current}</span>
|
||||
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.RadioGroup value={props.state.current} onChange={props.state.onSelect}>
|
||||
{props.state.options.map((value) => (
|
||||
<MenuV2.RadioItem value={value} class="capitalize">
|
||||
{value}
|
||||
</MenuV2.RadioItem>
|
||||
))}
|
||||
</MenuV2.RadioGroup>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<Select
|
||||
size="normal"
|
||||
options={props.state.options}
|
||||
current={props.state.current}
|
||||
onSelect={props.state.onSelect}
|
||||
class="max-w-[175px] justify-start text-v2-text-text-faint [&_[data-component=icon]]:text-v2-icon-icon-muted"
|
||||
valueClass="truncate pl-5 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
|
||||
triggerStyle={props.state.style}
|
||||
triggerProps={{ "data-action": "prompt-agent" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
const createdClients: string[] = []
|
||||
const createdSessions: string[] = []
|
||||
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
||||
const optimistic: Array<{
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
@@ -27,8 +27,6 @@ let params: { id?: string } = {}
|
||||
let search: { draftId?: string } = {}
|
||||
let selected = "/repo/worktree-a"
|
||||
let variant: string | undefined
|
||||
let permissionServer = "server-a"
|
||||
let createSessionGate: Promise<void> | undefined
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const prompt = {
|
||||
@@ -58,7 +56,6 @@ const clientFor = (directory: string) => {
|
||||
return {
|
||||
session: {
|
||||
create: async () => {
|
||||
await createSessionGate
|
||||
createdSessions.push(directory)
|
||||
return {
|
||||
data: {
|
||||
@@ -125,14 +122,13 @@ beforeAll(async () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/permission", () => {
|
||||
const state = (server: string) => ({
|
||||
mock.module("@/context/permission", () => ({
|
||||
usePermission: () => ({
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
enabledAutoAccept.push({ server, sessionID, directory })
|
||||
enabledAutoAccept.push({ sessionID, directory })
|
||||
},
|
||||
})
|
||||
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
|
||||
})
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/server", () => ({
|
||||
useServer: () => ({ key: "server-key" }),
|
||||
@@ -255,8 +251,6 @@ beforeEach(() => {
|
||||
syncedDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
permissionServer = "server-a"
|
||||
createSessionGate = undefined
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
})
|
||||
|
||||
@@ -324,40 +318,7 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
})
|
||||
|
||||
test("keeps auto-accept bound to the submission server", async () => {
|
||||
let release = () => {}
|
||||
createSessionGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => true,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
permissionServer = "server-b"
|
||||
release()
|
||||
await result
|
||||
|
||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
})
|
||||
|
||||
test("promotes drafts using the selected project's server", async () => {
|
||||
|
||||
@@ -315,7 +315,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
const projectDirectory = sdk().directory
|
||||
const permissionState = permission.currentServerState()
|
||||
const isNewSession = !params.id
|
||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
@@ -379,7 +378,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
createEffect,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
Show,
|
||||
splitProps,
|
||||
type Accessor,
|
||||
type ComponentProps,
|
||||
} from "solid-js"
|
||||
import { createEffect, For, onCleanup, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -193,28 +184,9 @@ export function PromptProjectSelector(props: {
|
||||
controller: PromptProjectController
|
||||
placement?: "bottom" | "bottom-start"
|
||||
}) {
|
||||
const [triggerReady, setTriggerReady] = createSignal(false)
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let triggerFrame: number | undefined
|
||||
let restoreTrigger = true
|
||||
|
||||
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
|
||||
const setTriggerRef = (element: HTMLButtonElement) => {
|
||||
const ready = () => {
|
||||
if (!element.isConnected) {
|
||||
triggerFrame = requestAnimationFrame(ready)
|
||||
return
|
||||
}
|
||||
triggerFrame = undefined
|
||||
setTriggerReady(true)
|
||||
}
|
||||
ready()
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (triggerFrame !== undefined) cancelAnimationFrame(triggerFrame)
|
||||
})
|
||||
|
||||
const activeItem = () =>
|
||||
props.controller.active()
|
||||
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
||||
@@ -285,13 +257,13 @@ export function PromptProjectSelector(props: {
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={triggerReady() && props.controller.open()}
|
||||
open={props.controller.open()}
|
||||
placement={props.placement ?? "bottom"}
|
||||
gutter={4}
|
||||
modal={false}
|
||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
||||
>
|
||||
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
|
||||
<DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
ref={contentRef}
|
||||
|
||||
@@ -7,7 +7,7 @@ export function NewSessionDesignView(props: { children: JSX.Element }) {
|
||||
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
||||
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
|
||||
<div class="mt-8">{props.children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,12 +65,9 @@ export function SortableTerminalTabV2(props: {
|
||||
|
||||
const focus = () => {
|
||||
if (store.editing) return
|
||||
terminal.requestFocus(props.terminal.id)
|
||||
terminal.open(props.terminal.id)
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
focusTerminalById(props.terminal.id)
|
||||
const input = document.getElementById(`terminal-wrapper-${props.terminal.id}`)?.querySelector("textarea")
|
||||
if (input === document.activeElement) terminal.consumeFocus(props.terminal.id)
|
||||
}
|
||||
|
||||
const edit = (e?: Event) => {
|
||||
|
||||
@@ -379,6 +379,7 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -371,7 +371,7 @@
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.settings-v2-models {
|
||||
.settings-v2-tab-body.settings-v2-models {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
|
||||
|
||||
describe("serverStatusDotClass", () => {
|
||||
test("uses the success token while the server and services are healthy", () => {
|
||||
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
|
||||
})
|
||||
|
||||
test("uses the warning token for non-blocking issues while the server is online", () => {
|
||||
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
|
||||
})
|
||||
|
||||
test("uses the critical token only after the server connection drops", () => {
|
||||
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base")
|
||||
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
|
||||
})
|
||||
|
||||
test("stays neutral before status is ready", () => {
|
||||
expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
|
||||
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasNonBlockingServiceIssue", () => {
|
||||
test("detects MCP failures that do not block chatting", () => {
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
|
||||
})
|
||||
|
||||
test("detects LSP failures that do not block chatting", () => {
|
||||
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export function hasNonBlockingServiceIssue(input: {
|
||||
mcp: Array<McpStatus["status"]>
|
||||
lsp: Array<LspStatus["status"]>
|
||||
}) {
|
||||
return (
|
||||
input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
|
||||
input.lsp.some((status) => status === "error")
|
||||
)
|
||||
}
|
||||
|
||||
export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
|
||||
if (input.serverHealth === false) return "bg-icon-critical-base"
|
||||
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
|
||||
if (input.issue) return "bg-icon-warning-base"
|
||||
if (input.serverHealth === true) return "bg-icon-success-base"
|
||||
return "bg-border-weak-base"
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
|
||||
|
||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
|
||||
@@ -20,14 +19,16 @@ export function StatusPopover() {
|
||||
const global = useGlobal()
|
||||
const sync = useSync()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||
const issue = createMemo(() =>
|
||||
hasNonBlockingServiceIssue({
|
||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||
}),
|
||||
)
|
||||
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
|
||||
const mcpIssue = createMemo(() => {
|
||||
const mcp = Object.values(sync().data.mcp ?? {})
|
||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
||||
const warn = mcp.some((item) => item.status === "needs_auth")
|
||||
if (failed) return "critical" as const
|
||||
if (warn) return "warning" as const
|
||||
})
|
||||
const serverHealthy = () => global.servers.health[server.key]?.healthy === true
|
||||
const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
|
||||
|
||||
return (
|
||||
<Popover
|
||||
@@ -46,11 +47,13 @@ export function StatusPopover() {
|
||||
<Icon name={shown() ? "status-active" : "status"} size="small" />
|
||||
</div>
|
||||
<div
|
||||
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
|
||||
ready: ready(),
|
||||
serverHealth: serverHealth(),
|
||||
issue: issue(),
|
||||
})}`}
|
||||
classList={{
|
||||
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": ready() && healthy(),
|
||||
"bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
|
||||
"bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
|
||||
"bg-border-weak-base": serverHealthy() || !ready(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@@ -84,18 +87,21 @@ function DirectoryStatusPopover() {
|
||||
const sync = useSync()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
|
||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||
const issue = createMemo(() =>
|
||||
hasNonBlockingServiceIssue({
|
||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||
}),
|
||||
)
|
||||
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
|
||||
const mcpIssue = createMemo(() => {
|
||||
const mcp = Object.values(sync().data.mcp ?? {})
|
||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
||||
const warn = mcp.some((item) => item.status === "needs_auth")
|
||||
if (failed) return "critical" as const
|
||||
if (warn) return "warning" as const
|
||||
})
|
||||
const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
|
||||
const state = createMemo<StatusPopoverState>(() => ({
|
||||
shown: shown(),
|
||||
ready: ready(),
|
||||
healthy: healthy(),
|
||||
serverHealth: serverHealth(),
|
||||
issue: issue(),
|
||||
issue: mcpIssue(),
|
||||
label: language.t("status.popover.trigger"),
|
||||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
@@ -117,8 +123,8 @@ function ServerStatusPopover() {
|
||||
const state = createMemo<StatusPopoverState>(() => ({
|
||||
shown: shown(),
|
||||
ready: serverHealth() !== undefined,
|
||||
healthy: serverHealth() === true,
|
||||
serverHealth: serverHealth(),
|
||||
issue: false,
|
||||
label: language.t("status.popover.trigger"),
|
||||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
@@ -134,8 +140,9 @@ function ServerStatusPopover() {
|
||||
type StatusPopoverState = {
|
||||
shown: boolean
|
||||
ready: boolean
|
||||
healthy: boolean
|
||||
serverHealth: boolean | undefined
|
||||
issue: boolean
|
||||
issue?: "critical" | "warning"
|
||||
label: string
|
||||
onOpenChange: (value: boolean) => void
|
||||
body: () => JSX.Element
|
||||
@@ -154,6 +161,16 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
|
||||
}
|
||||
|
||||
function StatusPopoverView(props: { state: StatusPopoverState }) {
|
||||
const statusDotClass = () => ({
|
||||
"absolute rounded-full": true,
|
||||
"bg-icon-success-base": props.state.ready && props.state.healthy,
|
||||
"bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
|
||||
"bg-icon-critical-base":
|
||||
props.state.serverHealth === false ||
|
||||
(props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
|
||||
"bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
|
||||
})
|
||||
|
||||
const popoverProps = {
|
||||
class:
|
||||
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
|
||||
@@ -178,7 +195,8 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
|
||||
<div class="relative size-4">
|
||||
<IconV2 name={props.state.shown ? "status-active" : "status"} />
|
||||
<div
|
||||
class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
|
||||
classList={statusDotClass()}
|
||||
class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||
export interface TerminalProps extends ComponentProps<"div"> {
|
||||
pty: LocalPTY
|
||||
autoFocus?: boolean
|
||||
onAutoFocus?: () => void
|
||||
onSubmit?: () => void
|
||||
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
||||
onConnect?: () => void
|
||||
@@ -186,15 +185,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const authToken = connection.type === "http" ? connection.authToken : false
|
||||
const sameOrigin = new URL(url, location.href).origin === location.origin
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, [
|
||||
"pty",
|
||||
"class",
|
||||
"classList",
|
||||
"autoFocus",
|
||||
"onAutoFocus",
|
||||
"onConnect",
|
||||
"onConnectError",
|
||||
])
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||
const id = local.pty.id
|
||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||
const restoreSize =
|
||||
@@ -425,7 +416,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
fitAddon = fit
|
||||
serializeAddon = serializer
|
||||
|
||||
const active = document.activeElement
|
||||
t.open(container)
|
||||
useTerminalUiBindings({
|
||||
container,
|
||||
@@ -435,22 +425,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
handleLinkClick,
|
||||
})
|
||||
|
||||
if (local.autoFocus === true) {
|
||||
focusTerminal()
|
||||
local.onAutoFocus?.()
|
||||
}
|
||||
if (local.autoFocus !== true) {
|
||||
const restoreFocus = () => {
|
||||
const current = document.activeElement
|
||||
if (current !== container && !container.contains(current)) return
|
||||
t.blur()
|
||||
t.textarea?.blur()
|
||||
if (active instanceof HTMLElement && active.isConnected) active.focus()
|
||||
}
|
||||
restoreFocus()
|
||||
const timer = setTimeout(restoreFocus, 0)
|
||||
cleanups.push(() => clearTimeout(timer))
|
||||
}
|
||||
if (local.autoFocus !== false) focusTerminal()
|
||||
|
||||
if (typeof document !== "undefined" && document.fonts) {
|
||||
void document.fonts.ready.then(scheduleFit)
|
||||
|
||||
@@ -392,7 +392,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
id: "tab.new",
|
||||
category: "tab",
|
||||
title: language.t("command.session.new"),
|
||||
keybind: "mod+t,mod+n",
|
||||
keybind: "mod+t",
|
||||
hidden: true,
|
||||
onSelect: openNewTab,
|
||||
},
|
||||
|
||||
@@ -221,56 +221,4 @@ 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,8 +45,6 @@ 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
|
||||
@@ -120,8 +118,6 @@ 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()
|
||||
@@ -186,27 +182,20 @@ 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), enabled: instanceQueriesEnabled() }))
|
||||
const pathQuery = useQuery(() => input.queryOptions.path(key))
|
||||
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), enabled: instanceQueriesEnabled() }))
|
||||
const providerQuery = useQuery(() => ({
|
||||
...input.queryOptions.providers(key),
|
||||
enabled: instanceQueriesEnabled(),
|
||||
}))
|
||||
const referenceQuery = useQuery(() => ({
|
||||
...input.queryOptions.references(key),
|
||||
enabled: instanceQueriesEnabled(),
|
||||
}))
|
||||
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
|
||||
const providerQuery = useQuery(() => input.queryOptions.providers(key))
|
||||
const referenceQuery = useQuery(() => input.queryOptions.references(key))
|
||||
|
||||
const child = createStore<State>({
|
||||
project: "",
|
||||
projectMeta: initialMeta,
|
||||
icon: initialIcon,
|
||||
get provider_ready() {
|
||||
return instanceQueriesEnabled() && !providerQuery.isLoading
|
||||
return !providerQuery.isLoading
|
||||
},
|
||||
get provider() {
|
||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||
@@ -247,7 +236,7 @@ export function createChildStoreManager(input: {
|
||||
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
|
||||
},
|
||||
get lsp_ready() {
|
||||
return instanceQueriesEnabled() && !lspQuery.isLoading
|
||||
return !lspQuery.isLoading
|
||||
},
|
||||
get lsp() {
|
||||
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
||||
@@ -261,7 +250,6 @@ 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
|
||||
@@ -302,7 +290,6 @@ 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)
|
||||
}
|
||||
@@ -314,7 +301,6 @@ 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)
|
||||
}
|
||||
@@ -328,16 +314,6 @@ 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
|
||||
@@ -384,7 +360,6 @@ export function createChildStoreManager(input: {
|
||||
unpin,
|
||||
pinned,
|
||||
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
||||
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
|
||||
disableMcp,
|
||||
disposeDirectory,
|
||||
runEviction,
|
||||
|
||||
@@ -243,22 +243,6 @@ 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,14 +146,15 @@ 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
|
||||
input.setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
if (result.found) {
|
||||
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))
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionV2Info } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
applyHomeSessionEvent,
|
||||
appendHomeSessionEvent,
|
||||
HOME_V2_SESSION_PAGE_LIMIT,
|
||||
loadHomeSessionIndex,
|
||||
homeSessionIndexSessions,
|
||||
homeSessionIndexRefresh,
|
||||
parseHomeSessionIndex,
|
||||
retainHomeSessions,
|
||||
} from "./home-session-index"
|
||||
|
||||
const session = (input: {
|
||||
id: string
|
||||
directory?: string
|
||||
parentID?: string
|
||||
archived?: number
|
||||
updated?: number
|
||||
}) => ({
|
||||
id: input.id,
|
||||
parentID: input.parentID,
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: input.updated ?? 1, archived: input.archived },
|
||||
title: input.id,
|
||||
location: { directory: input.directory ?? "/project" },
|
||||
})
|
||||
|
||||
describe("Home V2 session index", () => {
|
||||
test("loads the Home index with one global V2 request", async () => {
|
||||
const calls: unknown[] = []
|
||||
const result = await loadHomeSessionIndex(async (input) => {
|
||||
calls.push(input)
|
||||
return { data: { data: [session({ id: "root" })], cursor: {} } }
|
||||
})
|
||||
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }])
|
||||
})
|
||||
|
||||
test("loads subsequent pages until the session index is complete", async () => {
|
||||
const calls: unknown[] = []
|
||||
const controller = new AbortController()
|
||||
const result = await loadHomeSessionIndex(
|
||||
async (input, options) => {
|
||||
calls.push({ input, signal: options.signal })
|
||||
if (!("cursor" in input)) {
|
||||
return {
|
||||
data: {
|
||||
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
|
||||
session({ id: `page-1-${index}` }),
|
||||
),
|
||||
cursor: { next: "next-page" },
|
||||
},
|
||||
}
|
||||
}
|
||||
return { data: { data: [session({ id: "page-2" })], cursor: {} } }
|
||||
},
|
||||
0,
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
|
||||
expect(calls).toEqual([
|
||||
{ input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal },
|
||||
{
|
||||
input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" },
|
||||
signal: controller.signal,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("maps visible roots to Home session summaries", () => {
|
||||
const activeNull = {
|
||||
...session({ id: "active-null", updated: 20 }),
|
||||
time: { created: 1, updated: 20, archived: null },
|
||||
} as unknown as SessionV2Info
|
||||
const result = parseHomeSessionIndex([
|
||||
session({ id: "root", updated: 30 }),
|
||||
activeNull,
|
||||
session({ id: "child", parentID: "root", updated: 40 }),
|
||||
session({ id: "archived", archived: 50, updated: 50 }),
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "root",
|
||||
slug: "root",
|
||||
version: "",
|
||||
directory: "/project",
|
||||
projectID: "project",
|
||||
title: "root",
|
||||
time: { created: 1, updated: 30 },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "active-null",
|
||||
time: { created: 1, updated: 20, archived: null },
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves the per-directory Home retention limit", () => {
|
||||
const now = 10 * 60 * 60 * 1000
|
||||
const sessions = Array.from({ length: 80 }, (_, index) => ({
|
||||
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
|
||||
directory: index % 2 === 0 ? "/one" : "/two",
|
||||
}))
|
||||
|
||||
const retained = retainHomeSessions(sessions, 10, now)
|
||||
expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
|
||||
expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
|
||||
})
|
||||
|
||||
test("replays session events over the loaded index", () => {
|
||||
const initial = parseHomeSessionIndex([session({ id: "old" })])
|
||||
const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
|
||||
|
||||
const afterCreate = applyHomeSessionEvent(initial, {
|
||||
type: "session.created",
|
||||
properties: { sessionID: created.id, info: created },
|
||||
})
|
||||
expect(
|
||||
applyHomeSessionEvent(afterCreate, {
|
||||
type: "session.deleted",
|
||||
properties: { sessionID: initial[0]!.id, info: initial[0]! },
|
||||
}),
|
||||
).toEqual([created])
|
||||
})
|
||||
|
||||
test("applies only events newer than the index baseline", () => {
|
||||
const initial = parseHomeSessionIndex([session({ id: "old" })])
|
||||
const stale = { ...initial[0], title: "stale" }
|
||||
const current = { ...initial[0], title: "current" }
|
||||
const first = appendHomeSessionEvent(undefined, {
|
||||
type: "session.updated",
|
||||
properties: { sessionID: stale.id, info: stale },
|
||||
})
|
||||
const events = appendHomeSessionEvent(first, {
|
||||
type: "session.updated",
|
||||
properties: { sessionID: current.id, info: current },
|
||||
})
|
||||
|
||||
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
|
||||
})
|
||||
|
||||
test("refetches after reconnect, disposal, and session moves", () => {
|
||||
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
|
||||
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
|
||||
expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
|
||||
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,174 +0,0 @@
|
||||
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type { QueryClient } from "@tanstack/solid-query"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
|
||||
|
||||
export type HomeSessionEvent = {
|
||||
type: "session.created" | "session.updated" | "session.deleted"
|
||||
properties: { sessionID: string; info: Session }
|
||||
}
|
||||
export type HomeSessionEvents = {
|
||||
sequence: number
|
||||
entries: Array<{ sequence: number; event: HomeSessionEvent }>
|
||||
}
|
||||
export type HomeSessionIndex = {
|
||||
sessions: Session[]
|
||||
eventSequence: number
|
||||
}
|
||||
|
||||
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
|
||||
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
|
||||
|
||||
type HomeSessionPage = { data?: V2SessionListResponse }
|
||||
|
||||
export async function loadHomeSessionIndex(
|
||||
list: (
|
||||
input: { limit: number; order: "desc"; cursor?: string },
|
||||
options: { signal?: AbortSignal },
|
||||
) => Promise<HomeSessionPage>,
|
||||
eventSequence = 0,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const data: SessionV2Info[] = []
|
||||
let cursor: string | undefined
|
||||
|
||||
for (;;) {
|
||||
const response = await list(
|
||||
{
|
||||
limit: HOME_V2_SESSION_PAGE_LIMIT,
|
||||
order: "desc",
|
||||
...(cursor ? { cursor } : {}),
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
const page = response.data!
|
||||
data.push(...page.data)
|
||||
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
|
||||
return { sessions: parseHomeSessionIndex(data), eventSequence }
|
||||
cursor = page.cursor.next
|
||||
}
|
||||
}
|
||||
|
||||
export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) {
|
||||
const sequence = (current?.sequence ?? 0) + 1
|
||||
return {
|
||||
sequence,
|
||||
entries: [...(current?.entries ?? []), { sequence, event }],
|
||||
}
|
||||
}
|
||||
|
||||
export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents {
|
||||
return {
|
||||
sequence: current?.sequence ?? sequence,
|
||||
entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence),
|
||||
}
|
||||
}
|
||||
|
||||
export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
|
||||
if (!index) return []
|
||||
return (events?.entries ?? [])
|
||||
.filter((entry) => entry.sequence > index.eventSequence)
|
||||
.reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions)
|
||||
}
|
||||
|
||||
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
|
||||
if (event === "server.connected") return { connected: true, refetch: connected }
|
||||
return {
|
||||
connected,
|
||||
refetch: event === "global.disposed" || event === "session.next.moved",
|
||||
}
|
||||
}
|
||||
|
||||
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
|
||||
const indexKey = homeSessionIndexKey(server)
|
||||
const eventsKey = homeSessionEventsKey(server)
|
||||
let connected = false
|
||||
|
||||
return {
|
||||
indexKey,
|
||||
eventsKey,
|
||||
eventSequence() {
|
||||
return queryClient.getQueryData<HomeSessionEvents>(eventsKey)?.sequence ?? 0
|
||||
},
|
||||
complete(sequence: number) {
|
||||
// Keep events received after the fetch began so its response cannot overwrite them.
|
||||
queryClient.setQueryData<HomeSessionEvents>(eventsKey, (current) => trimHomeSessionEvents(current, sequence))
|
||||
},
|
||||
sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
|
||||
return homeSessionIndexSessions(index, events)
|
||||
},
|
||||
apply(event: HomeSessionEvent) {
|
||||
if (!queryClient.getQueryState(indexKey)) return
|
||||
const next = appendHomeSessionEvent(queryClient.getQueryData<HomeSessionEvents>(eventsKey), event)
|
||||
if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) {
|
||||
queryClient.setQueryData(eventsKey, next)
|
||||
return
|
||||
}
|
||||
|
||||
const index = queryClient.getQueryData<HomeSessionIndex>(indexKey)
|
||||
if (index) {
|
||||
queryClient.setQueryData<HomeSessionIndex>(indexKey, {
|
||||
sessions: homeSessionIndexSessions(index, next),
|
||||
eventSequence: next.sequence,
|
||||
})
|
||||
}
|
||||
queryClient.setQueryData<HomeSessionEvents>(eventsKey, { sequence: next.sequence, entries: [] })
|
||||
},
|
||||
refresh(event: Event["type"]) {
|
||||
const result = homeSessionIndexRefresh(event, connected)
|
||||
connected = result.connected
|
||||
if (!result.refetch) return
|
||||
void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(v2): This deliberately dumb full-table scan is necessary because the
|
||||
// current V2 API orders by creation time and cannot filter roots, archives, or
|
||||
// multiple directories. A bounded page could omit an old session updated today.
|
||||
// Once released, use client.v2.project.list() and client.v2.session.list({
|
||||
// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
|
||||
export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
|
||||
return sessions.flatMap((item) => {
|
||||
if (item.parentID || typeof item.time.archived === "number") return []
|
||||
return [toLegacySummary(item)]
|
||||
})
|
||||
}
|
||||
|
||||
export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
|
||||
const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
|
||||
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
|
||||
}
|
||||
|
||||
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
|
||||
const info = event.properties.info
|
||||
const index = sessions.findIndex((session) => session.id === info.id)
|
||||
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
|
||||
if (index === -1) return sessions
|
||||
return sessions.toSpliced(index, 1)
|
||||
}
|
||||
if (event.type !== "session.created" && event.type !== "session.updated") return sessions
|
||||
if (index === -1) return [...sessions, info]
|
||||
return sessions.with(index, info)
|
||||
}
|
||||
|
||||
function toLegacySummary(session: SessionV2Info): Session {
|
||||
return {
|
||||
id: session.id,
|
||||
slug: session.id,
|
||||
projectID: session.projectID,
|
||||
workspaceID: session.location.workspaceID,
|
||||
directory: session.location.directory,
|
||||
path: session.subpath,
|
||||
parentID: session.parentID,
|
||||
cost: session.cost,
|
||||
tokens: session.tokens,
|
||||
title: session.title,
|
||||
agent: session.agent,
|
||||
model: session.model,
|
||||
version: "",
|
||||
time: session.time,
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { hasCustomAgent, resolveAgent } from "./local-agent"
|
||||
|
||||
describe("hasCustomAgent", () => {
|
||||
test("detects explicitly custom agents", () => {
|
||||
expect(hasCustomAgent([{ native: true }, { native: false }])).toBe(true)
|
||||
})
|
||||
|
||||
test("ignores built-in and unclassified agents", () => {
|
||||
expect(hasCustomAgent([{ native: true }, {}])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveAgent", () => {
|
||||
const agents = [{ name: "plan" }, { name: "build" }, { name: "custom" }]
|
||||
|
||||
test("uses the requested available agent", () => {
|
||||
expect(resolveAgent(agents, "custom")?.name).toBe("custom")
|
||||
})
|
||||
|
||||
test("defaults to build", () => {
|
||||
expect(resolveAgent(agents)?.name).toBe("build")
|
||||
expect(resolveAgent(agents, "missing")?.name).toBe("build")
|
||||
})
|
||||
|
||||
test("uses the first agent when build is unavailable", () => {
|
||||
expect(resolveAgent([{ name: "custom" }], "missing")?.name).toBe("custom")
|
||||
})
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
export function hasCustomAgent(items: Array<{ native?: boolean }>) {
|
||||
return items.some((item) => item.native === false)
|
||||
}
|
||||
|
||||
export function resolveAgent<T extends { name: string }>(items: T[], name?: string) {
|
||||
return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0]
|
||||
}
|
||||
@@ -4,10 +4,8 @@ import { useParams } from "@solidjs/router"
|
||||
import { batch, createEffect, createMemo, startTransition } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useModels } from "@/context/models"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { hasCustomAgent, resolveAgent } from "./local-agent"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
@@ -64,11 +62,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const serverSDK = useServerSDK()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const models = useModels()
|
||||
const settings = useSettings()
|
||||
|
||||
const id = createMemo(() => params.id || undefined)
|
||||
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
const [saved, setSaved, , savedReady] = persisted(
|
||||
@@ -111,7 +107,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const pickAgent = (name: string | undefined) => {
|
||||
return resolveAgent(list(), name)
|
||||
const items = list()
|
||||
if (items.length === 0) return
|
||||
return items.find((item) => item.name === name) ?? items[0]
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -182,9 +180,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const agent = {
|
||||
list,
|
||||
visible: agentsVisible,
|
||||
current() {
|
||||
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build")
|
||||
return pickAgent(scope()?.agent ?? store.current)
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
const item = pickAgent(name)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
|
||||
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
({
|
||||
@@ -69,7 +69,6 @@ describe("autoRespondsPermission", () => {
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
|
||||
expect(sessionAutoAccept(autoAccept, sessions, permission("root"), directory)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("session-level override takes precedence over directory-level", () => {
|
||||
@@ -82,28 +81,6 @@ describe("autoRespondsPermission", () => {
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
|
||||
})
|
||||
|
||||
test("parent false override takes precedence over directory-level auto-accept", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/*`]: true,
|
||||
[`${base64Encode(directory)}/root`]: false,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
|
||||
})
|
||||
|
||||
test("parent true override takes precedence over disabled directory fallback", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/*`]: false,
|
||||
[`${base64Encode(directory)}/root`]: true,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isDirectoryAutoAccepting", () => {
|
||||
|
||||
@@ -11,7 +11,8 @@ export function directoryAcceptKey(directory: string) {
|
||||
|
||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
return autoAccept[key] ?? autoAccept[sessionID]
|
||||
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
|
||||
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
|
||||
}
|
||||
|
||||
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
||||
@@ -43,18 +44,8 @@ export function autoRespondsPermission(
|
||||
permission: { sessionID: string },
|
||||
directory?: string,
|
||||
) {
|
||||
const value = sessionAutoAccept(autoAccept, session, permission, directory)
|
||||
if (value !== undefined) return value
|
||||
return directory ? isDirectoryAutoAccepting(autoAccept, directory) : false
|
||||
}
|
||||
|
||||
export function sessionAutoAccept(
|
||||
autoAccept: Record<string, boolean>,
|
||||
session: { id: string; parentID?: string }[],
|
||||
permission: { sessionID: string },
|
||||
directory?: string,
|
||||
) {
|
||||
return sessionLineage(session, permission.sessionID)
|
||||
const value = sessionLineage(session, permission.sessionID)
|
||||
.map((id) => accepted(autoAccept, id, directory))
|
||||
.find((item): item is boolean => item !== undefined)
|
||||
return value ?? false
|
||||
}
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { ServerSDK } from "@/context/server-sdk"
|
||||
import type { ServerSync } from "./server-sync"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useGlobal } from "./global"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { type DraftTab, useTabs } from "./tabs"
|
||||
import { useSettings } from "./settings"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import {
|
||||
acceptKey,
|
||||
directoryAcceptKey,
|
||||
isDirectoryAutoAccepting,
|
||||
autoRespondsPermission,
|
||||
sessionAutoAccept,
|
||||
} from "./permission-auto-respond"
|
||||
|
||||
type PermissionRespondFn = (input: {
|
||||
@@ -54,417 +47,234 @@ function hasPermissionPromptRules(permission: unknown) {
|
||||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||
name: "Permission",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const settings = useSettings()
|
||||
const owner = getOwner()
|
||||
const states = new Map<ServerScope, { key: ServerConnection.Key; dispose: () => void; state: PermissionState }>()
|
||||
|
||||
const activeDraft = createMemo(() => {
|
||||
if (!search.draftId) return
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||
})
|
||||
|
||||
const activeServer = createMemo(() => {
|
||||
if (params.serverKey && settings.general.newLayoutDesigns()) return requireServerKey(params.serverKey)
|
||||
return activeDraft()?.server ?? server.key
|
||||
})
|
||||
|
||||
const ensure = (key: ServerConnection.Key) => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||
if (!conn) throw new Error(`Permission server not found: ${key}`)
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
const existing = states.get(ctx.sdk.scope)
|
||||
if (existing && global.servers.list().some((item) => ServerConnection.key(item) === existing.key)) {
|
||||
return existing.state
|
||||
}
|
||||
if (existing) {
|
||||
existing.dispose()
|
||||
states.delete(ctx.sdk.scope)
|
||||
}
|
||||
const root = createRoot(
|
||||
(dispose) => ({
|
||||
key,
|
||||
dispose,
|
||||
state: createServerPermissionState({ sdk: ctx.sdk, sync: ctx.sync }),
|
||||
}),
|
||||
owner ?? undefined,
|
||||
)
|
||||
states.set(ctx.sdk.scope, root)
|
||||
return root.state
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const list = global.servers.list()
|
||||
const keys = new Set(list.map(ServerConnection.key))
|
||||
states.forEach((value, scope) => {
|
||||
if (keys.has(value.key)) return
|
||||
value.dispose()
|
||||
states.delete(scope)
|
||||
const replacement = list.find((conn) => server.scope(ServerConnection.key(conn)) === scope)
|
||||
if (replacement) ensure(ServerConnection.key(replacement))
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => states.forEach((value) => value.dispose()))
|
||||
|
||||
let lastSelected: PermissionState | undefined
|
||||
const selected = () => {
|
||||
const key = activeServer()
|
||||
if (global.servers.list().some((conn) => ServerConnection.key(conn) === key)) {
|
||||
lastSelected = ensure(key)
|
||||
}
|
||||
if (lastSelected) return lastSelected
|
||||
return ensure(server.key)
|
||||
}
|
||||
const activeDirectory = createMemo(() => {
|
||||
const directory = decode64(params.dir)
|
||||
if (directory) return directory
|
||||
const draft = activeDraft()
|
||||
if (draft) return draft.directory
|
||||
if (!params.id) return
|
||||
if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
|
||||
return selected().sync.session.lineage.peek(params.id)?.session.directory
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const directory = activeDirectory()
|
||||
if (!directory) return
|
||||
selected().enableConfiguredDirectory(directory)
|
||||
})
|
||||
init: (props: { directory?: Accessor<string | undefined> }) => {
|
||||
const params = useParams()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
|
||||
const permissionsEnabled = createMemo(() => {
|
||||
const directory = activeDirectory()
|
||||
const directory = props.directory?.() ?? decode64(params.dir)
|
||||
if (!directory) return false
|
||||
return selected().permissionsEnabled(directory)
|
||||
const [store] = serverSync().child(directory)
|
||||
return hasPermissionPromptRules(store.config.permission)
|
||||
})
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]),
|
||||
migrate(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||
|
||||
const data = value as Record<string, unknown>
|
||||
if (data.autoAccept) return value
|
||||
|
||||
return {
|
||||
...data,
|
||||
autoAccept:
|
||||
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
|
||||
? data.autoAcceptEdits
|
||||
: {},
|
||||
}
|
||||
},
|
||||
},
|
||||
createStore({
|
||||
autoAccept: {} as Record<string, boolean>,
|
||||
}),
|
||||
)
|
||||
|
||||
// When config has permission: "allow", auto-enable directory-level auto-accept
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
const directory = props.directory?.() ?? decode64(params.dir)
|
||||
if (!directory) return
|
||||
const [childStore] = serverSync().child(directory)
|
||||
const perm = childStore.config.permission
|
||||
if (typeof perm === "string" && perm === "allow") {
|
||||
const key = directoryAcceptKey(directory)
|
||||
if (store.autoAccept[key] === undefined) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const MAX_RESPONDED = 1000
|
||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||
const responded = new Map<string, number>()
|
||||
const enableVersion = new Map<string, number>()
|
||||
|
||||
function pruneResponded(now: number) {
|
||||
for (const [id, ts] of responded) {
|
||||
if (now - ts < RESPONDED_TTL_MS) break
|
||||
responded.delete(id)
|
||||
}
|
||||
|
||||
for (const id of responded.keys()) {
|
||||
if (responded.size <= MAX_RESPONDED) break
|
||||
responded.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
const respond: PermissionRespondFn = (input) => {
|
||||
serverSDK()
|
||||
.client.permission.respond(input)
|
||||
.catch(() => {
|
||||
responded.delete(input.permissionID)
|
||||
})
|
||||
}
|
||||
|
||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||
const now = Date.now()
|
||||
const hit = responded.has(permission.id)
|
||||
responded.delete(permission.id)
|
||||
responded.set(permission.id, now)
|
||||
pruneResponded(now)
|
||||
if (hit) return
|
||||
respond({
|
||||
sessionID: permission.sessionID,
|
||||
permissionID: permission.id,
|
||||
response: "once",
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
||||
}
|
||||
|
||||
function isAutoAcceptingDirectory(directory: string) {
|
||||
return isDirectoryAutoAccepting(store.autoAccept, directory)
|
||||
}
|
||||
|
||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
||||
}
|
||||
|
||||
function bumpEnableVersion(sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const next = (enableVersion.get(key) ?? 0) + 1
|
||||
enableVersion.set(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const unsubscribe = serverSDK().event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
|
||||
const perm = event.properties
|
||||
if (!shouldAutoRespond(perm, e.name)) return
|
||||
|
||||
respondOnce(perm, e.name)
|
||||
})
|
||||
onCleanup(unsubscribe)
|
||||
|
||||
function enableDirectory(directory: string) {
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
|
||||
serverSDK()
|
||||
.client.permission.list({ directory })
|
||||
.then((x) => {
|
||||
if (!isAutoAcceptingDirectory(directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
if (!shouldAutoRespond(perm, directory)) continue
|
||||
respondOnce(perm, directory)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disableDirectory(directory: string) {
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function enable(sessionID: string, directory: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const version = bumpEnableVersion(sessionID, directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
|
||||
serverSDK()
|
||||
.client.permission.list({ directory })
|
||||
.then((x) => {
|
||||
if (enableVersion.get(key) !== version) return
|
||||
if (!isAutoAccepting(sessionID, directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
if (!shouldAutoRespond(perm, directory)) continue
|
||||
respondOnce(perm, directory)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disable(sessionID: string, directory?: string) {
|
||||
bumpEnableVersion(sessionID, directory)
|
||||
const key = directory ? acceptKey(sessionID, directory) : sessionID
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
if (!directory) return
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
ready: () => selected().ready(),
|
||||
ensureServerState: (key: ServerConnection.Key) => ensure(key).api,
|
||||
currentServerState: () => selected().api,
|
||||
respond(input: Parameters<PermissionRespondFn>[0]) {
|
||||
selected().respond(input)
|
||||
},
|
||||
ready,
|
||||
respond,
|
||||
autoResponds(permission: PermissionRequest, directory?: string) {
|
||||
return selected().autoResponds(permission, directory)
|
||||
},
|
||||
isAutoAccepting(sessionID: string, directory?: string) {
|
||||
return selected().isAutoAccepting(sessionID, directory)
|
||||
},
|
||||
isAutoAcceptingDirectory(directory: string) {
|
||||
return selected().isAutoAcceptingDirectory(directory)
|
||||
return shouldAutoRespond(permission, directory)
|
||||
},
|
||||
isAutoAccepting,
|
||||
isAutoAcceptingDirectory,
|
||||
toggleAutoAccept(sessionID: string, directory: string) {
|
||||
selected().toggleAutoAccept(sessionID, directory)
|
||||
if (isAutoAccepting(sessionID, directory)) {
|
||||
disable(sessionID, directory)
|
||||
return
|
||||
}
|
||||
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
toggleAutoAcceptDirectory(directory: string) {
|
||||
selected().toggleAutoAcceptDirectory(directory)
|
||||
if (isAutoAcceptingDirectory(directory)) {
|
||||
disableDirectory(directory)
|
||||
return
|
||||
}
|
||||
enableDirectory(directory)
|
||||
},
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
selected().enableAutoAccept(sessionID, directory)
|
||||
if (isAutoAccepting(sessionID, directory)) return
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
disableAutoAccept(sessionID: string, directory?: string) {
|
||||
selected().disableAutoAccept(sessionID, directory)
|
||||
disable(sessionID, directory)
|
||||
},
|
||||
permissionsEnabled,
|
||||
isPermissionAllowAll(directory: string) {
|
||||
return selected().isPermissionAllowAll(directory)
|
||||
const [childStore] = serverSync().child(directory)
|
||||
const perm = childStore.config.permission
|
||||
return typeof perm === "string" && perm === "allow"
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type PermissionState = ReturnType<typeof createServerPermissionState>
|
||||
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
|
||||
|
||||
function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
|
||||
migrate(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||
|
||||
const data = value as Record<string, unknown>
|
||||
if (data.autoAccept) return value
|
||||
|
||||
return {
|
||||
...data,
|
||||
autoAccept:
|
||||
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
|
||||
? data.autoAcceptEdits
|
||||
: {},
|
||||
}
|
||||
},
|
||||
},
|
||||
createStore({
|
||||
autoAccept: {} as Record<string, boolean>,
|
||||
}),
|
||||
)
|
||||
|
||||
function enableConfiguredDirectory(directory: string) {
|
||||
if (meta.disposed || !ready()) return
|
||||
const [childStore] = input.sync.child(directory)
|
||||
if (childStore.config.permission !== "allow") return
|
||||
const key = directoryAcceptKey(directory)
|
||||
if (store.autoAccept[key] !== undefined) return
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_RESPONDED = 1000
|
||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||
const responded = new Map<string, number>()
|
||||
const enableVersion = new Map<string, number>()
|
||||
const meta = { disposed: false }
|
||||
|
||||
function pruneResponded(now: number) {
|
||||
for (const [id, ts] of responded) {
|
||||
if (now - ts < RESPONDED_TTL_MS) break
|
||||
responded.delete(id)
|
||||
}
|
||||
|
||||
for (const id of responded.keys()) {
|
||||
if (responded.size <= MAX_RESPONDED) break
|
||||
responded.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
const respond: PermissionRespondFn = (request) => {
|
||||
if (meta.disposed) return
|
||||
input.sdk.client.permission.respond(request).catch(() => {
|
||||
responded.delete(request.permissionID)
|
||||
})
|
||||
}
|
||||
|
||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||
const now = Date.now()
|
||||
const hit = responded.has(permission.id)
|
||||
responded.delete(permission.id)
|
||||
responded.set(permission.id, now)
|
||||
pruneResponded(now)
|
||||
if (hit) return
|
||||
respond({
|
||||
sessionID: permission.sessionID,
|
||||
permissionID: permission.id,
|
||||
response: "once",
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
function sessions(directory?: string) {
|
||||
const info = Object.values(input.sync.session.data.info).filter((session) => !!session)
|
||||
if (!directory) return info
|
||||
return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session]
|
||||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
return autoRespondsPermission(store.autoAccept, sessions(directory), { sessionID }, directory)
|
||||
}
|
||||
|
||||
function isAutoAcceptingDirectory(directory: string) {
|
||||
return isDirectoryAutoAccepting(store.autoAccept, directory)
|
||||
}
|
||||
|
||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||
return autoRespondsPermission(store.autoAccept, sessions(directory), permission, directory)
|
||||
}
|
||||
|
||||
function isPending(permission: PermissionRequest) {
|
||||
const pending = input.sync.session.data.permission[permission.sessionID]
|
||||
return pending === undefined || pending.some((item) => item.id === permission.id)
|
||||
}
|
||||
|
||||
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
|
||||
const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory)
|
||||
if (override !== undefined) return override
|
||||
if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
|
||||
const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined)
|
||||
if (meta.disposed || !lineage) return false
|
||||
return shouldAutoRespond(permission, directory)
|
||||
}
|
||||
|
||||
async function respondPending(
|
||||
permission: PermissionRequest,
|
||||
directory?: string,
|
||||
current: () => boolean = () => true,
|
||||
) {
|
||||
if (!current() || !isPending(permission)) return
|
||||
if (!(await shouldAutoRespondResolved(permission, directory))) return
|
||||
if (meta.disposed || !current() || !isPending(permission)) return
|
||||
respondOnce(permission, directory)
|
||||
}
|
||||
|
||||
function bumpEnableVersion(sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const next = (enableVersion.get(key) ?? 0) + 1
|
||||
enableVersion.set(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const handlePermission = (e: PermissionEvent) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
void respondPending(event.properties, e.name)
|
||||
}
|
||||
|
||||
const unsubscribe = input.sdk.event.listen((event) => {
|
||||
if (ready()) {
|
||||
handlePermission(event)
|
||||
return
|
||||
}
|
||||
void ready.promise?.then(() => {
|
||||
if (meta.disposed) return
|
||||
handlePermission(event)
|
||||
})
|
||||
})
|
||||
onCleanup(() => {
|
||||
meta.disposed = true
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
function enableDirectory(directory: string) {
|
||||
if (meta.disposed) return
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
|
||||
input.sdk.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
if (meta.disposed) return
|
||||
if (!isAutoAcceptingDirectory(directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory))
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disableDirectory(directory: string) {
|
||||
if (meta.disposed) return
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function enable(sessionID: string, directory: string) {
|
||||
if (meta.disposed) return
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const version = bumpEnableVersion(sessionID, directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
|
||||
input.sdk.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
if (meta.disposed) return
|
||||
if (enableVersion.get(key) !== version) return
|
||||
if (!isAutoAccepting(sessionID, directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
void respondPending(
|
||||
perm,
|
||||
directory,
|
||||
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disable(sessionID: string, directory?: string) {
|
||||
if (meta.disposed) return
|
||||
bumpEnableVersion(sessionID, directory)
|
||||
const key = directory ? acceptKey(sessionID, directory) : sessionID
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
if (!directory) return
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const api = {
|
||||
ready: () => !meta.disposed && ready(),
|
||||
respond,
|
||||
autoResponds(permission: PermissionRequest, directory?: string) {
|
||||
if (meta.disposed) return false
|
||||
return shouldAutoRespond(permission, directory)
|
||||
},
|
||||
isAutoAccepting(sessionID: string, directory?: string) {
|
||||
if (meta.disposed) return false
|
||||
return isAutoAccepting(sessionID, directory)
|
||||
},
|
||||
isAutoAcceptingDirectory(directory: string) {
|
||||
if (meta.disposed) return false
|
||||
return isAutoAcceptingDirectory(directory)
|
||||
},
|
||||
toggleAutoAccept(sessionID: string, directory: string) {
|
||||
if (meta.disposed) return
|
||||
if (isAutoAccepting(sessionID, directory)) {
|
||||
disable(sessionID, directory)
|
||||
return
|
||||
}
|
||||
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
toggleAutoAcceptDirectory(directory: string) {
|
||||
if (meta.disposed) return
|
||||
if (isAutoAcceptingDirectory(directory)) {
|
||||
disableDirectory(directory)
|
||||
return
|
||||
}
|
||||
enableDirectory(directory)
|
||||
},
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
if (meta.disposed) return
|
||||
if (isAutoAccepting(sessionID, directory)) return
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
disableAutoAccept(sessionID: string, directory?: string) {
|
||||
if (meta.disposed) return
|
||||
disable(sessionID, directory)
|
||||
},
|
||||
isPermissionAllowAll(directory: string) {
|
||||
if (meta.disposed) return false
|
||||
const [childStore] = input.sync.child(directory)
|
||||
return childStore.config.permission === "allow"
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
...api,
|
||||
api,
|
||||
sync: input.sync,
|
||||
enableConfiguredDirectory,
|
||||
permissionsEnabled(directory: string) {
|
||||
if (meta.disposed) return false
|
||||
const [childStore] = input.sync.child(directory)
|
||||
return hasPermissionPromptRules(childStore.config.permission)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ describe("server session", () => {
|
||||
await ctx.store.sync("root")
|
||||
|
||||
expect(ctx.get).toEqual([{ sessionID: "root" }])
|
||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }])
|
||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 2, before: undefined }])
|
||||
expect(ctx.store.data.message.root).toEqual([])
|
||||
})
|
||||
|
||||
@@ -194,7 +194,7 @@ describe("server session", () => {
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }])
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
expect(store.history.more("child")).toBe(true)
|
||||
|
||||
@@ -21,7 +21,7 @@ import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } fro
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const initialMessagePageSize = 20
|
||||
const initialMessagePageSize = 2
|
||||
const historyMessagePageSize = 200
|
||||
const sessionInfoLimit = 2_048
|
||||
const emptyIDs: ReadonlySet<string> = new Set()
|
||||
|
||||
@@ -43,7 +43,6 @@ 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"
|
||||
@@ -154,7 +153,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
|
||||
|
||||
let bootedAt = 0
|
||||
let bootingRoot = false
|
||||
@@ -378,10 +376,6 @@ 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({
|
||||
@@ -396,7 +390,6 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -412,19 +405,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
directory,
|
||||
store,
|
||||
setStore,
|
||||
push: (directory) => {
|
||||
if (children.active(directory)) queue.push(directory)
|
||||
},
|
||||
push: queue.push,
|
||||
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))
|
||||
},
|
||||
})
|
||||
@@ -497,7 +486,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
project: projectApi,
|
||||
session,
|
||||
homeSessions,
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
const key = directoryKey(directory)
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
isAppUpgrade,
|
||||
layoutTransitionState,
|
||||
maximumSunsetTimeout,
|
||||
newLayoutDesignsDefault,
|
||||
nextSunsetCheckDelay,
|
||||
resolveNewLayoutDesigns,
|
||||
shouldDisplayTabsToast,
|
||||
shouldEnableNewLayout,
|
||||
} from "./settings"
|
||||
|
||||
describe("layout transition", () => {
|
||||
@@ -40,33 +37,4 @@ describe("layout transition", () => {
|
||||
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
|
||||
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
|
||||
})
|
||||
|
||||
test("enables the new layout when upgrading from 1.17.19 or earlier", () => {
|
||||
expect(shouldEnableNewLayout("v1.17.19", "1.17.20")).toBe(true)
|
||||
expect(shouldEnableNewLayout("1.16.9", "2.0.0")).toBe(true)
|
||||
})
|
||||
|
||||
test("enables the new layout when no previous version was recorded", () => {
|
||||
expect(shouldEnableNewLayout(undefined, "1.17.20")).toBe(true)
|
||||
})
|
||||
|
||||
test("detects upgrades only when a previous version is older", () => {
|
||||
expect(isAppUpgrade("1.17.19", "1.17.20")).toBe(true)
|
||||
expect(isAppUpgrade(undefined, "1.17.20")).toBe(false)
|
||||
expect(isAppUpgrade("1.17.20", "1.17.20")).toBe(false)
|
||||
expect(isAppUpgrade("1.17.21", "1.17.20")).toBe(false)
|
||||
})
|
||||
|
||||
test("shows the tabs toast for upgrades and existing installs without a recorded version", () => {
|
||||
expect(shouldDisplayTabsToast("1.17.19", "1.17.20", false)).toBe(true)
|
||||
expect(shouldDisplayTabsToast(undefined, "1.17.20", true)).toBe(true)
|
||||
expect(shouldDisplayTabsToast(undefined, "1.17.20", false)).toBe(false)
|
||||
})
|
||||
|
||||
test("does not enable the new layout without a qualifying upgrade", () => {
|
||||
expect(shouldEnableNewLayout("1.17.19", "1.17.19")).toBe(false)
|
||||
expect(shouldEnableNewLayout("1.17.20", "1.17.21")).toBe(false)
|
||||
expect(shouldEnableNewLayout(undefined, "1.17.19")).toBe(false)
|
||||
expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createStore, reconcile } from "solid-js/store"
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
|
||||
export interface NotificationSettings {
|
||||
agent: boolean
|
||||
@@ -37,7 +36,6 @@ export interface Settings {
|
||||
newLayoutDesigns?: boolean
|
||||
layoutTransitionEligible?: boolean
|
||||
newInterfaceNoticeDismissed?: boolean
|
||||
shouldDisplayTabsToast?: boolean
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
@@ -59,49 +57,7 @@ export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
export const newLayoutDesignsDefault = true
|
||||
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
|
||||
export const oldInterfaceSunset = new Date(2026, 8, 14)
|
||||
const newLayoutDesignsUpgradeCutoff = "1.17.19"
|
||||
|
||||
function compareVersions(a: string, b: string) {
|
||||
const parse = (version: string) => {
|
||||
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
|
||||
if (!match) return
|
||||
return match.slice(1).map(Number)
|
||||
}
|
||||
const left = parse(a)
|
||||
const right = parse(b)
|
||||
if (!left || !right) return
|
||||
const index = left.findIndex((part, index) => part !== right[index])
|
||||
return index === -1 ? 0 : left[index]! - right[index]!
|
||||
}
|
||||
|
||||
export function isAppUpgrade(previous: string | undefined, current: string | undefined) {
|
||||
if (!previous || !current) return false
|
||||
const comparison = compareVersions(current, previous)
|
||||
return comparison !== undefined && comparison > 0
|
||||
}
|
||||
|
||||
export function shouldDisplayTabsToast(
|
||||
previous: string | undefined,
|
||||
current: string | undefined,
|
||||
existingInstall: boolean,
|
||||
) {
|
||||
return isAppUpgrade(previous, current) || (!previous && existingInstall)
|
||||
}
|
||||
|
||||
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
|
||||
if (!current) return false
|
||||
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
|
||||
if (!previous) return currentComparison !== undefined && currentComparison > 0
|
||||
if (!isAppUpgrade(previous, current)) return false
|
||||
const previousComparison = compareVersions(previous, newLayoutDesignsUpgradeCutoff)
|
||||
return (
|
||||
previousComparison !== undefined &&
|
||||
currentComparison !== undefined &&
|
||||
previousComparison <= 0 &&
|
||||
currentComparison > 0
|
||||
)
|
||||
}
|
||||
export const oldInterfaceSunset = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" ? new Date(2026, 7, 14) : null
|
||||
|
||||
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
|
||||
return {
|
||||
@@ -219,17 +175,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
name: "Settings",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||
const [launch, setLaunch, , launchReady] = persisted(
|
||||
"app-version.v1",
|
||||
createStore<{ version?: string }>({ version: undefined }),
|
||||
)
|
||||
const [launchState, setLaunchState] = createStore({
|
||||
classified: false,
|
||||
migrationApplied: false,
|
||||
previous: undefined as string | undefined,
|
||||
})
|
||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||
@@ -239,26 +185,18 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
)
|
||||
const sunset = oldInterfaceSunset
|
||||
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
|
||||
const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
|
||||
const layoutTransitionClassified = createMemo(
|
||||
() => typeof store.general?.layoutTransitionEligible === "boolean",
|
||||
)
|
||||
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
|
||||
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
|
||||
const layoutUpgrade = createMemo(() =>
|
||||
launchState.classified && !launchState.migrationApplied
|
||||
? shouldEnableNewLayout(launchState.previous, platform.version)
|
||||
: false,
|
||||
)
|
||||
const layoutTransition = createMemo(() =>
|
||||
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
||||
)
|
||||
const newLayoutDesigns = createMemo(() => {
|
||||
if (layoutUpgrade()) return true
|
||||
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
||||
if (!layoutTransitionClassified()) {
|
||||
return resolveNewLayoutDesigns(
|
||||
oldInterfaceRetired(),
|
||||
store.general?.newLayoutDesigns,
|
||||
legacyNewLayoutDesignsDefault,
|
||||
)
|
||||
return resolveNewLayoutDesigns(oldInterfaceRetired(), store.general?.newLayoutDesigns, legacyNewLayoutDesignsDefault)
|
||||
}
|
||||
return resolveNewLayoutDesigns(
|
||||
oldInterfaceRetired(),
|
||||
@@ -283,35 +221,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!launchReady() || launchState.classified) return
|
||||
setLaunchState({
|
||||
classified: true,
|
||||
previous: launch.version,
|
||||
})
|
||||
if (!platform.version || launch.version === platform.version) return
|
||||
setLaunch("version", platform.version)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !launchState.classified || launchState.migrationApplied) return
|
||||
if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
|
||||
setStore("general", "newLayoutDesigns", true)
|
||||
}
|
||||
setLaunchState("migrationApplied", true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !launchState.classified) return
|
||||
if (typeof store.general?.shouldDisplayTabsToast === "boolean") return
|
||||
if (!launchState.previous && !layoutTransitionClassified()) return
|
||||
setStore(
|
||||
"general",
|
||||
"shouldDisplayTabsToast",
|
||||
shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()),
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !oldInterfaceRetired()) return
|
||||
if (store.general?.newLayoutDesigns === true) return
|
||||
@@ -405,10 +314,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
},
|
||||
newLayoutDesigns,
|
||||
setNewLayoutDesigns(value: boolean) {
|
||||
const next = oldInterfaceRetired() ? true : value
|
||||
if (newLayoutDesigns() === next) return
|
||||
setStore("general", "newLayoutDesigns", next)
|
||||
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
|
||||
setStore("general", "newLayoutDesigns", oldInterfaceRetired() ? true : value)
|
||||
},
|
||||
layoutTransitionClassified,
|
||||
setOldLayoutEligible(eligible: boolean) {
|
||||
@@ -421,10 +327,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
dismissNewInterfaceNotice() {
|
||||
setStore("general", "newInterfaceNoticeDismissed", true)
|
||||
},
|
||||
shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
|
||||
dismissTabsToast() {
|
||||
setStore("general", "shouldDisplayTabsToast", false)
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
fileTree: visible(showFileTree),
|
||||
|
||||
@@ -208,11 +208,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
||||
return tab
|
||||
},
|
||||
async newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
|
||||
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
|
||||
const draftID = uuid()
|
||||
const tab = { type: "draft" as const, draftID, ...draft }
|
||||
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
|
||||
await startTransition(() => {
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.push(tab)
|
||||
@@ -220,7 +220,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
)
|
||||
navigate(draftHref(draftID))
|
||||
})
|
||||
return tab
|
||||
},
|
||||
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
||||
void startTransition(() => {
|
||||
|
||||
@@ -163,43 +163,6 @@ function createWorkspaceTerminalSession(
|
||||
all: [],
|
||||
}),
|
||||
)
|
||||
const [ui, setUi] = createStore({
|
||||
focus: undefined as { request: number; id?: string; pending: boolean } | undefined,
|
||||
})
|
||||
const focus = { request: 0 }
|
||||
|
||||
const requestFocus = (id?: string, pending = false) => {
|
||||
focus.request += 1
|
||||
setUi("focus", { request: focus.request, id, pending })
|
||||
return focus.request
|
||||
}
|
||||
|
||||
const focusRequested = (id?: string) => {
|
||||
if (!id) return false
|
||||
if (!ui.focus || ui.focus.pending) return false
|
||||
return !ui.focus.id || ui.focus.id === id
|
||||
}
|
||||
|
||||
const consumeFocus = (id: string) => {
|
||||
if (!focusRequested(id)) return
|
||||
setUi("focus", undefined)
|
||||
}
|
||||
|
||||
const cancelFocus = (request?: number) => {
|
||||
if (request !== undefined && ui.focus?.request !== request) return
|
||||
setUi("focus", undefined)
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
const cancelOnOutsideFocus = (event: FocusEvent) => {
|
||||
if (!ui.focus) return
|
||||
if (!(event.target instanceof Element)) return
|
||||
if (event.target.closest("#terminal-panel")) return
|
||||
cancelFocus()
|
||||
}
|
||||
document.addEventListener("focusin", cancelOnOutsideFocus)
|
||||
onCleanup(() => document.removeEventListener("focusin", cancelOnOutsideFocus))
|
||||
}
|
||||
|
||||
const pickNextTerminalNumber = () => {
|
||||
const existingTitleNumbers = new Set(
|
||||
@@ -304,33 +267,23 @@ function createWorkspaceTerminalSession(
|
||||
setStore("all", [])
|
||||
})
|
||||
},
|
||||
new(options?: { focus?: boolean }) {
|
||||
new() {
|
||||
const nextNumber = pickNextTerminalNumber()
|
||||
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
|
||||
|
||||
sdk.client.pty
|
||||
.create({ title: defaultTitle(nextNumber) })
|
||||
.then((pty: { data?: { id?: string; title?: string } }) => {
|
||||
const id = pty.data?.id
|
||||
if (!id) {
|
||||
if (focusRequest !== undefined) cancelFocus(focusRequest)
|
||||
return
|
||||
}
|
||||
if (!id) return
|
||||
const newTerminal = {
|
||||
id,
|
||||
title: pty.data?.title ?? defaultTitle(nextNumber),
|
||||
titleNumber: nextNumber,
|
||||
}
|
||||
batch(() => {
|
||||
setStore("all", store.all.length, newTerminal)
|
||||
setStore("active", id)
|
||||
if (focusRequest !== undefined && ui.focus?.request === focusRequest) {
|
||||
setUi("focus", { request: focusRequest, id, pending: false })
|
||||
}
|
||||
})
|
||||
setStore("all", store.all.length, newTerminal)
|
||||
setStore("active", id)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (focusRequest !== undefined) cancelFocus(focusRequest)
|
||||
console.error("Failed to create terminal", error)
|
||||
})
|
||||
},
|
||||
@@ -371,18 +324,6 @@ function createWorkspaceTerminalSession(
|
||||
open(id: string) {
|
||||
setStore("active", id)
|
||||
},
|
||||
requestFocus(id?: string) {
|
||||
requestFocus(id)
|
||||
},
|
||||
focusRequested(id?: string) {
|
||||
return focusRequested(id)
|
||||
},
|
||||
consumeFocus(id: string) {
|
||||
consumeFocus(id)
|
||||
},
|
||||
cancelFocus() {
|
||||
cancelFocus()
|
||||
},
|
||||
next() {
|
||||
const index = store.all.findIndex((x) => x.id === store.active)
|
||||
if (index === -1) return
|
||||
@@ -501,17 +442,13 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
ready: () => workspace().ready(),
|
||||
all: () => workspace().all(),
|
||||
active: () => workspace().active(),
|
||||
new: (options?: { focus?: boolean }) => workspace().new(options),
|
||||
new: () => workspace().new(),
|
||||
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
||||
trim: (id: string) => workspace().trim(id),
|
||||
trimAll: () => workspace().trimAll(),
|
||||
clone: (id: string) => workspace().clone(id),
|
||||
bind: () => workspace(),
|
||||
open: (id: string) => workspace().open(id),
|
||||
requestFocus: (id?: string) => workspace().requestFocus(id),
|
||||
focusRequested: (id?: string) => workspace().focusRequested(id),
|
||||
consumeFocus: (id: string) => workspace().consumeFocus(id),
|
||||
cancelFocus: () => workspace().cancelFocus(),
|
||||
close: (id: string) => workspace().close(id),
|
||||
move: (id: string, to: number) => workspace().move(id, to),
|
||||
next: () => workspace().next(),
|
||||
|
||||
@@ -35,12 +35,12 @@ import { usePlatform } from "@/context/platform"
|
||||
import { DateTime } from "luxon"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import {
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
getProjectAvatarSource,
|
||||
homeProjectDirectories,
|
||||
projectForSession,
|
||||
sortedRootSessions,
|
||||
toggleHomeProjectSelection,
|
||||
} from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
@@ -68,11 +69,6 @@ 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
|
||||
@@ -110,24 +106,22 @@ const HOME_SEARCH_RESULT_META =
|
||||
let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
|
||||
|
||||
function buildHomeSessionRecords(input: {
|
||||
sessions: () => Session[]
|
||||
sync: Pick<ServerSync, "child">
|
||||
projectDirectories: () => string[]
|
||||
projects: () => LocalProject[]
|
||||
projectByID: () => Map<string, LocalProject>
|
||||
}) {
|
||||
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()]
|
||||
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(),
|
||||
]
|
||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.flatMap((session) => {
|
||||
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())
|
||||
const project = projectForSession(session, input.projects(), input.projectByID())
|
||||
if (!project) return []
|
||||
return {
|
||||
session,
|
||||
@@ -275,7 +269,7 @@ export function NewHome() {
|
||||
const command = useCommand()
|
||||
const notification = useNotification()
|
||||
const marked = useMarked()
|
||||
const openSettings = useSettingsCommand()
|
||||
const openSettings = useSettingsDialog()
|
||||
let focusSessionSearch: (() => void) | undefined
|
||||
const [state, setState] = createStore({
|
||||
search: "",
|
||||
@@ -292,7 +286,6 @@ 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(),
|
||||
@@ -325,47 +318,24 @@ 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: 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,
|
||||
queryKey: ["home", "sessions", selection().server, ...projectDirectories()] as const,
|
||||
queryFn: async () => {
|
||||
await Promise.all(
|
||||
projectDirectories().map((directory) =>
|
||||
focusedSync().project.loadSessions(directory, { limit: HOME_SESSION_LIMIT }),
|
||||
),
|
||||
)
|
||||
cache.complete(eventSequence)
|
||||
return index
|
||||
return null
|
||||
},
|
||||
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({
|
||||
sessions: indexedSessions,
|
||||
sync: focusedSync(),
|
||||
projectDirectories,
|
||||
projects,
|
||||
projectByID,
|
||||
@@ -393,7 +363,8 @@ export function NewHome() {
|
||||
prefetched.add(key)
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
void ctx.sync.session
|
||||
const directory = ctx.sync.ensureDirSyncContext(record.session.directory)
|
||||
void directory.session
|
||||
.sync(record.session.id)
|
||||
.then(() => {
|
||||
return Promise.all(
|
||||
@@ -513,13 +484,7 @@ export function NewHome() {
|
||||
}
|
||||
|
||||
function openSession(session: Session, options?: OpenSessionOptions) {
|
||||
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 project = projectForSession(session, projects(), projectByID())
|
||||
const conn = focusedServer()
|
||||
if (!conn) return
|
||||
const directory = project?.worktree ?? session.directory
|
||||
@@ -830,7 +795,7 @@ function HomeUtilityNav(props: {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
return (
|
||||
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
|
||||
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1`}>
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { TabsInfoPopup } from "@/components/help-button"
|
||||
import { HelpButton, TabsInfoPopup } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { setNavigate } from "@/utils/notification-click"
|
||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
|
||||
export default function NewLayout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const navigate = useNavigate()
|
||||
setNavigate(navigate)
|
||||
useSettingsCommand()
|
||||
|
||||
createEffect(() => setV2Toast(true))
|
||||
|
||||
@@ -38,6 +40,7 @@ export default function NewLayout(props: ParentProps) {
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar inline />}
|
||||
<TabsInfoPopup />
|
||||
<HelpButton />
|
||||
<ToastRegion v2 />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { TabsInfoPopup } from "@/components/help-button"
|
||||
import { HelpButton, TabsInfoPopup } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
@@ -2396,6 +2396,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
||||
</div>
|
||||
<TabsInfoPopup />
|
||||
<HelpButton />
|
||||
<ToastRegion v2={false} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ export function useSessionTabAvatarState(
|
||||
const global = useGlobal()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const permissionState = createMemo(() => permission.ensureServerState(server()))
|
||||
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
||||
const sync = createMemo(() => {
|
||||
const conn = connection()
|
||||
@@ -24,7 +23,7 @@ export function useSessionTabAvatarState(
|
||||
if (!serverSync) return false
|
||||
const [store] = serverSync.child(directory(), { bootstrap: false })
|
||||
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
|
||||
return !permissionState().autoResponds(item, directory())
|
||||
return !permission.autoResponds(item, directory())
|
||||
})
|
||||
})
|
||||
const hasQuestions = createMemo(() => {
|
||||
|
||||
@@ -28,7 +28,7 @@ import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSettingsCommand, useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import createPresence from "solid-presence"
|
||||
import { useLocal } from "@/context/local"
|
||||
@@ -54,7 +54,6 @@ export default function NewSessionPage() {
|
||||
const command = useCommand()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const openProviderSettings = useSettingsDialog("providers")
|
||||
useSettingsCommand()
|
||||
const route = useSessionKey()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const local = useLocal()
|
||||
|
||||
@@ -47,6 +47,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { PromptProvider, usePrompt } from "@/context/prompt"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
@@ -57,9 +58,6 @@ import { useSync } from "@/context/sync"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { TerminalProvider, useTerminal } from "@/context/terminal"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
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,
|
||||
@@ -155,25 +153,13 @@ export function SessionPage() {
|
||||
// workspace-scoped state (terminal, directory providers) lives below.
|
||||
export function TargetSessionRouteContent() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const serverSync = useServerSync()
|
||||
const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory)
|
||||
return (
|
||||
// Settings must keep the target-server SDK, sync, and models context and remain registered
|
||||
// when session content falls back to the route error boundary.
|
||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||
<TargetSessionSettingsCommand />
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
||||
<ResolvedTargetSessionRoute />
|
||||
</SessionRouteErrorBoundary>
|
||||
</TargetServerScopedProviders>
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
||||
<ResolvedTargetSessionRoute />
|
||||
</SessionRouteErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetSessionSettingsCommand() {
|
||||
useSettingsCommand()
|
||||
return null
|
||||
}
|
||||
|
||||
export function SessionRouteErrorBoundary(
|
||||
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
|
||||
) {
|
||||
@@ -262,17 +248,19 @@ function ResolvedTargetSessionRoute() {
|
||||
})
|
||||
|
||||
return (
|
||||
// Non-keyed: closes only while the target's directory is unknown (uncached
|
||||
// lineage mid-resolution), which tears down the workspace subtree including
|
||||
// the terminal. Same-workspace tab switches keep it open because warm
|
||||
// targets resolve synchronously from the sync cache.
|
||||
<Show when={directory()}>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||
{/* Non-keyed: closes only while the target's directory is unknown (uncached
|
||||
lineage mid-resolution), which tears down the workspace subtree including
|
||||
the terminal. Same-workspace tab switches keep it open because warm
|
||||
targets resolve synchronously from the sync cache. */}
|
||||
<Show when={directory()}>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
</TargetServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -293,10 +281,10 @@ function TargetServerScopedProviders(
|
||||
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1066,10 +1054,7 @@ export default function Page() {
|
||||
|
||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||
if (composer.blocked() || isChildSession()) return
|
||||
const input = inputRef
|
||||
if (!input) return
|
||||
input.focus()
|
||||
setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current()))
|
||||
inputRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export function createPromptInputController(input: {
|
||||
options: local.agent.list().map((agent) => agent.name),
|
||||
current: local.agent.current()?.name ?? "",
|
||||
loading: agentsQuery.isLoading,
|
||||
visible: local.agent.visible(),
|
||||
visible: settings.visibility.customAgents(),
|
||||
select: local.agent.set,
|
||||
},
|
||||
model: {
|
||||
|
||||
@@ -757,7 +757,7 @@ function SessionFileViewV2(props: { tab: string }) {
|
||||
commentsUi.onLineNumberSelectionEnd(range)
|
||||
}}
|
||||
search={search}
|
||||
class="select-text [--opencode-diffs-bg:var(--v2-background-bg-base)]"
|
||||
class="select-text"
|
||||
media={{
|
||||
mode: "auto",
|
||||
path: path(),
|
||||
|
||||
@@ -28,6 +28,7 @@ import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
const delays = [120, 240]
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
const sdk = useSDK()
|
||||
@@ -45,8 +46,6 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
let root: HTMLDivElement | undefined
|
||||
let tabList: HTMLDivElement | undefined
|
||||
|
||||
onCleanup(() => terminal.cancelFocus())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
autoCreated: false,
|
||||
recovered: {} as Record<string, boolean>,
|
||||
@@ -95,12 +94,36 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
),
|
||||
)
|
||||
|
||||
const focus = (id: string) => {
|
||||
focusTerminalById(id)
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (!opened()) return
|
||||
if (terminal.active() !== id) return
|
||||
focusTerminalById(id)
|
||||
})
|
||||
|
||||
const timers = delays.map((ms) =>
|
||||
window.setTimeout(() => {
|
||||
if (!opened()) return
|
||||
if (terminal.active() !== id) return
|
||||
focusTerminalById(id)
|
||||
}, ms),
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
for (const timer of timers) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [opened(), terminal.active(), terminal.focusRequested(terminal.active())] as const,
|
||||
([next, id, requested]) => {
|
||||
if (!next || !id || !requested) return
|
||||
focusTerminalById(id)
|
||||
() => [opened(), terminal.active()] as const,
|
||||
([next, id]) => {
|
||||
if (!next || !id) return
|
||||
const stop = focus(id)
|
||||
onCleanup(stop)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -262,15 +285,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class={newLayout() ? "!h-[52px] !flex-none" : "!h-auto !flex-none"}
|
||||
>
|
||||
<Tabs.List
|
||||
ref={tabList}
|
||||
class={newLayout() ? undefined : "h-10 border-b border-border-weaker-base"}
|
||||
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const active = document.activeElement
|
||||
if (event.target === active) return
|
||||
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
|
||||
}}
|
||||
>
|
||||
<Tabs.List ref={tabList} class={newLayout() ? undefined : "h-10 border-b border-border-weaker-base"}>
|
||||
<For each={all()}>
|
||||
{(pty, index) => (
|
||||
<SortableTerminalTabV2 terminal={pty} index={index} newLayout={newLayout()} onClose={close} />
|
||||
@@ -289,7 +304,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={() => terminal.new({ focus: true })}
|
||||
onClick={terminal.new}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
@@ -311,7 +326,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={() => terminal.new({ focus: true })}
|
||||
onClick={terminal.new}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
@@ -329,8 +344,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||
<Terminal
|
||||
pty={pty()}
|
||||
autoFocus={terminal.focusRequested(id)}
|
||||
onAutoFocus={() => terminal.consumeFocus(id)}
|
||||
autoFocus={opened()}
|
||||
class="!px-[14px]"
|
||||
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
|
||||
onCleanup={ops.update}
|
||||
|
||||
@@ -38,8 +38,6 @@ export function TerminalPanel() {
|
||||
const close = () => view().terminal.close()
|
||||
let root: HTMLDivElement | undefined
|
||||
|
||||
onCleanup(() => terminal.cancelFocus())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
autoCreated: false,
|
||||
activeDraggable: undefined as string | undefined,
|
||||
@@ -287,7 +285,7 @@ export function TerminalPanel() {
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={() => terminal.new()}
|
||||
onClick={terminal.new}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
@@ -305,7 +303,6 @@ export function TerminalPanel() {
|
||||
<Terminal
|
||||
pty={pty()}
|
||||
autoFocus={opened()}
|
||||
onAutoFocus={() => terminal.consumeFocus(id)}
|
||||
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
|
||||
onCleanup={ops.update}
|
||||
onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
|
||||
|
||||
@@ -72,7 +72,6 @@ import { useSync } from "@/context/sync"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
@@ -409,7 +408,6 @@ export function MessageTimeline(props: {
|
||||
return timelineRows().length
|
||||
},
|
||||
getScrollElement: () => listRoot() ?? null,
|
||||
observeElementOffset: observeElementOffsetReconnectAware,
|
||||
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
|
||||
initialMeasurementsCache: initialMeasurements,
|
||||
estimateSize: () => timelineFallbackItemSize,
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { type Virtualizer } from "@tanstack/solid-virtual"
|
||||
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
|
||||
test("matches only the scroll element or an ancestor containing it", () => {
|
||||
const route = document.createElement("section")
|
||||
const viewport = document.createElement("div")
|
||||
const child = document.createElement("div")
|
||||
const sibling = document.createElement("div")
|
||||
route.append(viewport)
|
||||
viewport.append(child)
|
||||
|
||||
expect(mutationNodesContainElement([viewport], viewport)).toBe(true)
|
||||
expect(mutationNodesContainElement([route], viewport)).toBe(true)
|
||||
expect(mutationNodesContainElement([child, sibling], viewport)).toBe(false)
|
||||
})
|
||||
|
||||
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
|
||||
const route = document.createElement("section")
|
||||
const viewport = document.createElement("div")
|
||||
const unrelated = document.createElement("div")
|
||||
route.append(viewport)
|
||||
document.body.append(route)
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow: window,
|
||||
scrollOffset: 79_400,
|
||||
options: {
|
||||
horizontal: false,
|
||||
isRtl: false,
|
||||
isScrollingResetDelay: 0,
|
||||
useScrollendEvent: false,
|
||||
},
|
||||
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||
const calls: [number, boolean][] = []
|
||||
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
|
||||
calls.push([offset, isScrolling])
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
document.body.append(unrelated)
|
||||
unrelated.remove()
|
||||
await frames(2)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
cleanup?.()
|
||||
route.remove()
|
||||
})
|
||||
|
||||
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
|
||||
const route = document.createElement("section")
|
||||
const viewport = document.createElement("div")
|
||||
route.append(viewport)
|
||||
document.body.append(route)
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow: window,
|
||||
scrollOffset: 79_400,
|
||||
options: {
|
||||
horizontal: false,
|
||||
isRtl: false,
|
||||
isScrollingResetDelay: 20,
|
||||
useScrollendEvent: false,
|
||||
},
|
||||
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||
const calls: number[] = []
|
||||
const cleanup = observeElementOffsetReconnectAware(instance, (offset) => {
|
||||
calls.push(offset)
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(1)
|
||||
expect(instance.scrollOffset).toBe(0)
|
||||
|
||||
instance.scrollOffset = 79_400
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
await frames(3)
|
||||
|
||||
expect(instance.scrollOffset).toBe(0)
|
||||
expect(calls).toEqual([0, 0])
|
||||
cleanup?.()
|
||||
route.remove()
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ name: "LTR", isRtl: false, expected: 240 },
|
||||
{ name: "RTL", isRtl: true, expected: -240 },
|
||||
])("reports the TanStack horizontal $name offset after reconnect", async ({ isRtl, expected }) => {
|
||||
const route = document.createElement("section")
|
||||
const viewport = document.createElement("div")
|
||||
route.append(viewport)
|
||||
document.body.append(route)
|
||||
viewport.scrollLeft = 240
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow: window,
|
||||
scrollOffset: 0,
|
||||
options: {
|
||||
horizontal: true,
|
||||
isRtl,
|
||||
isScrollingResetDelay: 0,
|
||||
useScrollendEvent: false,
|
||||
},
|
||||
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||
const calls: [number, boolean][] = []
|
||||
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
|
||||
calls.push([offset, isScrolling])
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3)
|
||||
|
||||
expect(calls).toEqual([[expected, false]])
|
||||
cleanup?.()
|
||||
route.remove()
|
||||
})
|
||||
|
||||
test("cleanup suppresses an already queued delegated offset callback", async () => {
|
||||
const viewport = document.createElement("div")
|
||||
document.body.append(viewport)
|
||||
viewport.scrollTop = 100
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow: window,
|
||||
scrollOffset: 0,
|
||||
options: {
|
||||
horizontal: false,
|
||||
isRtl: false,
|
||||
isScrollingResetDelay: 10,
|
||||
useScrollendEvent: false,
|
||||
},
|
||||
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||
const calls: [number, boolean][] = []
|
||||
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) =>
|
||||
calls.push([offset, isScrolling]),
|
||||
)
|
||||
|
||||
viewport.dispatchEvent(new Event("scroll"))
|
||||
cleanup?.()
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
|
||||
expect(calls).toEqual([[100, true]])
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
test("cleanup cancels reconnect checks and delegated offset observation", async () => {
|
||||
const route = document.createElement("section")
|
||||
const viewport = document.createElement("div")
|
||||
route.append(viewport)
|
||||
document.body.append(route)
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow: window,
|
||||
scrollOffset: 0,
|
||||
options: {
|
||||
horizontal: false,
|
||||
isRtl: false,
|
||||
isScrollingResetDelay: 50,
|
||||
useScrollendEvent: false,
|
||||
},
|
||||
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||
const calls: number[] = []
|
||||
const cleanup = observeElementOffsetReconnectAware(instance, (offset) => calls.push(offset))
|
||||
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
cleanup?.()
|
||||
instance.scrollOffset = 100
|
||||
viewport.dispatchEvent(new Event("scroll"))
|
||||
await frames(4)
|
||||
|
||||
expect(calls).toEqual([])
|
||||
route.remove()
|
||||
})
|
||||
|
||||
async function frames(count: number) {
|
||||
for (let index = 0; index < count; index++) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { observeElementOffset, type Virtualizer } from "@tanstack/solid-virtual"
|
||||
|
||||
export function observeElementOffsetReconnectAware<TScrollElement extends Element, TItemElement extends Element>(
|
||||
instance: Virtualizer<TScrollElement, TItemElement>,
|
||||
callback: (offset: number, isScrolling: boolean) => void,
|
||||
) {
|
||||
let active = true
|
||||
const deliver = (offset: number, isScrolling: boolean) => {
|
||||
if (!active) return
|
||||
callback(offset, isScrolling)
|
||||
}
|
||||
const cleanupOffset = observeElementOffset(instance, deliver)
|
||||
const element = instance.scrollElement
|
||||
const targetWindow = instance.targetWindow
|
||||
const root = element?.closest("main") ?? element?.ownerDocument.body
|
||||
if (!element || !targetWindow || !root)
|
||||
return () => {
|
||||
active = false
|
||||
cleanupOffset?.()
|
||||
}
|
||||
|
||||
let removed = false
|
||||
let frame: number | undefined
|
||||
const clearCheck = () => {
|
||||
if (frame === undefined) return
|
||||
targetWindow.cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
const startCheck = () => {
|
||||
clearCheck()
|
||||
const deadline = targetWindow.performance.now() + instance.options.isScrollingResetDelay
|
||||
let framesAfterDeadline = 0
|
||||
const check = (time: number) => {
|
||||
frame = undefined
|
||||
if (element.isConnected) {
|
||||
const offset = instance.options.horizontal
|
||||
? element.scrollLeft * (instance.options.isRtl ? -1 : 1)
|
||||
: element.scrollTop
|
||||
if (instance.scrollOffset === null || Math.abs(offset - instance.scrollOffset) > 1) deliver(offset, false)
|
||||
}
|
||||
if (time >= deadline) framesAfterDeadline += 1
|
||||
if (framesAfterDeadline >= 2) return
|
||||
frame = targetWindow.requestAnimationFrame(check)
|
||||
}
|
||||
frame = targetWindow.requestAnimationFrame(check)
|
||||
}
|
||||
const observer = new targetWindow.MutationObserver((records) => {
|
||||
if (!active) return
|
||||
records.forEach((record) => {
|
||||
if (record.target === element || element.contains(record.target)) return
|
||||
if (mutationNodesContainElement(record.removedNodes, element)) {
|
||||
removed = true
|
||||
clearCheck()
|
||||
}
|
||||
if (!removed || !element.isConnected || !mutationNodesContainElement(record.addedNodes, element)) return
|
||||
removed = false
|
||||
startCheck()
|
||||
})
|
||||
})
|
||||
// Session routes are replaced below persistent main; body is the fallback for isolated hosts.
|
||||
observer.observe(root, { childList: true, subtree: true })
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
observer.disconnect()
|
||||
clearCheck()
|
||||
cleanupOffset?.()
|
||||
}
|
||||
}
|
||||
|
||||
export function mutationNodesContainElement(nodes: Iterable<Node>, element: Element) {
|
||||
return [...nodes].some((node) => node === element || node.contains(element))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLocal, type ModelSelection } from "@/context/local"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
|
||||
import { useSessionLayout } from "./session-layout"
|
||||
@@ -18,6 +19,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const local = useLocal()
|
||||
const settings = useSettings()
|
||||
const { sessionKey } = useSessionLayout()
|
||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||
const model = input.model ?? local.model
|
||||
@@ -68,7 +70,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
||||
description: language.t("command.agent.cycle.description"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
disabled: !local.agent.visible(),
|
||||
disabled: !settings.visibility.customAgents(),
|
||||
onSelect: () => local.agent.move(1),
|
||||
}),
|
||||
agentCommand({
|
||||
@@ -76,7 +78,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
||||
title: language.t("command.agent.cycle.reverse"),
|
||||
description: language.t("command.agent.cycle.reverse.description"),
|
||||
keybind: "shift+mod+.",
|
||||
disabled: !local.agent.visible(),
|
||||
disabled: !settings.visibility.customAgents(),
|
||||
onSelect: () => local.agent.move(-1),
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -264,8 +264,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
|
||||
const openTerminal = () => {
|
||||
if (terminal.all().length > 0) terminal.new({ focus: true })
|
||||
if (terminal.all().length === 0) terminal.requestFocus()
|
||||
if (terminal.all().length > 0) terminal.new()
|
||||
view().terminal.open()
|
||||
}
|
||||
|
||||
@@ -499,15 +498,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.terminal.toggle"),
|
||||
keybind: "ctrl+`",
|
||||
slash: "terminal",
|
||||
onSelect: () => {
|
||||
if (view().terminal.opened()) {
|
||||
terminal.cancelFocus()
|
||||
view().terminal.close()
|
||||
return
|
||||
}
|
||||
terminal.requestFocus(terminal.active())
|
||||
view().terminal.open()
|
||||
},
|
||||
onSelect: () => view().terminal.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "review.toggle",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createStore } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
export function createReviewPanelV2State() {
|
||||
const [store, setStore, , ready] = persisted(
|
||||
const [store, setStore] = persisted(
|
||||
Persist.global("review-panel-v2"),
|
||||
createStore({
|
||||
sidebarOpened: true,
|
||||
@@ -24,7 +24,6 @@ export function createReviewPanelV2State() {
|
||||
return {
|
||||
sidebarOpened: () => store.sidebarOpened,
|
||||
sidebarWidth: () => store.sidebarWidth,
|
||||
sidebarTransition: ready,
|
||||
filter,
|
||||
setFilter,
|
||||
expandMode: () => store.expandMode,
|
||||
|
||||
@@ -200,7 +200,6 @@ function ReviewPanelV2Sidebar(props: {
|
||||
return (
|
||||
<SessionReviewV2Sidebar
|
||||
open={props.state.sidebarOpened()}
|
||||
transition={props.state.sidebarTransition()}
|
||||
title={props.title}
|
||||
stats={<DiffChanges changes={props.diffs()} />}
|
||||
filter={props.state.filter()}
|
||||
|
||||
@@ -19,7 +19,6 @@ const emptyFiles: string[] = []
|
||||
export type SessionFileBrowserState = {
|
||||
sidebarOpened: () => boolean
|
||||
sidebarWidth: () => number
|
||||
sidebarTransition: () => boolean
|
||||
resizeSidebar: (width: number) => void
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
@@ -97,7 +96,6 @@ export function SessionFileBrowserTab(props: {
|
||||
sidebar={
|
||||
<SessionReviewV2Sidebar
|
||||
open={sidebarOpened()}
|
||||
transition={props.state.sidebarTransition()}
|
||||
title={<span class="truncate">{title()}</span>}
|
||||
filter={filter()}
|
||||
onFilterChange={setFilter}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { beforeAll, expect, mock, test } from "bun:test"
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { createEffect, createRoot } from "solid-js"
|
||||
|
||||
let createReviewPanelV2State: typeof import("@/pages/session/v2/review-panel-v2-state").createReviewPanelV2State
|
||||
let read: ((value: string | null) => void) | undefined
|
||||
|
||||
const storage: AsyncStorage = {
|
||||
getItem: () => new Promise((resolve) => (read = resolve)),
|
||||
setItem: async () => undefined,
|
||||
removeItem: async () => undefined,
|
||||
clear: async () => undefined,
|
||||
key: async () => null,
|
||||
getLength: async () => 0,
|
||||
length: Promise.resolve(0),
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@opencode-ai/session-ui/v2/session-review-v2", () => ({
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT: 240,
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN: 200,
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX: 480,
|
||||
}))
|
||||
mock.module("@/context/platform", () => ({
|
||||
usePlatform: () => ({ platform: "desktop", storage: () => storage }),
|
||||
}))
|
||||
|
||||
createReviewPanelV2State = (await import("@/pages/session/v2/review-panel-v2-state")).createReviewPanelV2State
|
||||
})
|
||||
|
||||
test("enables sidebar motion only after custom width hydration", async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
createRoot((dispose) => {
|
||||
const state = createReviewPanelV2State()
|
||||
const transition =
|
||||
"sidebarTransition" in state && typeof state.sidebarTransition === "function"
|
||||
? (state.sidebarTransition as () => boolean)
|
||||
: undefined
|
||||
|
||||
try {
|
||||
expect(transition).toBeFunction()
|
||||
expect(transition?.()).toBeFalse()
|
||||
expect(state.sidebarWidth()).toBe(240)
|
||||
} catch (error) {
|
||||
dispose()
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!transition?.()) return
|
||||
try {
|
||||
expect(state.sidebarWidth()).toBe(360)
|
||||
dispose()
|
||||
resolve()
|
||||
} catch (error) {
|
||||
dispose()
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
read?.(JSON.stringify({ sidebarOpened: true, sidebarWidth: 360, expandMode: "collapse" }))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,32 +1,8 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createVirtualizer, defaultRangeExtractor, Virtualizer } from "@tanstack/solid-virtual"
|
||||
import { createVirtualizer, defaultRangeExtractor } 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)
|
||||
@@ -66,26 +42,23 @@ test("initial rect projects rows before a scroll element connects", () => {
|
||||
})
|
||||
})
|
||||
|
||||
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: () => {},
|
||||
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()
|
||||
})
|
||||
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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.18.2",
|
||||
"version": "1.17.18",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -9,7 +9,6 @@ import "./app.css"
|
||||
import { LanguageProvider } from "~/context/language"
|
||||
import { I18nProvider, useI18n } from "~/context/i18n"
|
||||
import { strip } from "~/lib/language"
|
||||
import { DesktopPromo } from "~/component/desktop-promo"
|
||||
|
||||
function AppMeta() {
|
||||
const i18n = useI18n()
|
||||
@@ -34,7 +33,6 @@ export default function App() {
|
||||
<MetaProvider>
|
||||
<AppMeta />
|
||||
<Suspense>{props.children}</Suspense>
|
||||
<DesktopPromo />
|
||||
</MetaProvider>
|
||||
</I18nProvider>
|
||||
</LanguageProvider>
|
||||
|
||||
Binary file not shown.
@@ -1,103 +0,0 @@
|
||||
[data-component="desktop-promo"] {
|
||||
--promo-background: hsl(0, 20%, 99%);
|
||||
--promo-background-weak: hsl(0, 8%, 97%);
|
||||
--promo-text: hsl(0, 1%, 39%);
|
||||
--promo-text-strong: hsl(0, 5%, 12%);
|
||||
--promo-border: hsla(0, 100%, 3%, 0.12);
|
||||
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
right: 1.5rem;
|
||||
bottom: 1.5rem;
|
||||
width: min(28rem, calc(100vw - 2rem));
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
color: var(--promo-text);
|
||||
border: 1px solid var(--promo-border);
|
||||
border-radius: 8px;
|
||||
background: var(--promo-background);
|
||||
box-shadow: 0 0.75rem 2rem rgb(0 0 0 / 15%);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--promo-background: hsl(0, 9%, 7%);
|
||||
--promo-background-weak: hsl(0, 6%, 10%);
|
||||
--promo-text: hsl(0, 4%, 71%);
|
||||
--promo-text-strong: hsl(0, 15%, 94%);
|
||||
--promo-border: hsl(0, 4%, 23%);
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
}
|
||||
|
||||
[data-slot="desktop-promo-link"] {
|
||||
display: block;
|
||||
color: var(--promo-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-slot="desktop-promo-link"]:focus-visible {
|
||||
outline: 2px solid var(--promo-text-strong);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: var(--promo-background-weak);
|
||||
}
|
||||
|
||||
[data-slot="desktop-promo-copy"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
[data-slot="desktop-promo-copy"] strong {
|
||||
color: var(--promo-text-strong);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-slot="desktop-promo-close"] {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
display: grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
background: rgb(0 0 0 / 70%);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
background 150ms ease;
|
||||
}
|
||||
|
||||
&:hover [data-slot="desktop-promo-close"],
|
||||
[data-slot="desktop-promo-close"]:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="desktop-promo-close"]:hover {
|
||||
background: rgb(0 0 0 / 90%);
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
[data-slot="desktop-promo-close"] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import "./desktop-promo.css"
|
||||
import { A, useLocation } from "@solidjs/router"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import desktopPromoVideo from "~/asset/lander/desktop-tabs-landscape.mp4"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
import { strip } from "~/lib/language"
|
||||
|
||||
export function DesktopPromo() {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const location = useLocation()
|
||||
const [visible, setVisible] = createSignal(true)
|
||||
const request = getRequestEvent()?.request
|
||||
const hostname = request ? new URL(request.url).hostname : typeof window === "object" ? window.location.hostname : ""
|
||||
const primaryHost =
|
||||
hostname === "opencode.ai" || hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={
|
||||
visible() &&
|
||||
primaryHost &&
|
||||
strip(location.pathname) !== "/download" &&
|
||||
!strip(location.pathname).startsWith("/download/")
|
||||
}
|
||||
>
|
||||
<aside data-component="desktop-promo">
|
||||
<A href={language.route("/download")} data-slot="desktop-promo-link">
|
||||
<video src={desktopPromoVideo} autoplay playsinline loop muted preload="metadata" aria-hidden="true" />
|
||||
<span data-slot="desktop-promo-copy">
|
||||
<strong>{i18n.t("home.promo.title")}</strong>
|
||||
<span>
|
||||
{i18n.t("home.promo.body")} {i18n.t("home.promo.cta")}
|
||||
</span>
|
||||
</span>
|
||||
</A>
|
||||
<button type="button" data-slot="desktop-promo-close" onClick={() => setVisible(false)}>
|
||||
<span class="sr-only">{i18n.t("home.promo.close")}</span>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path d="M5 5L15 15M15 5L5 15" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</aside>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -102,14 +102,10 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "شعار opencode الداكن",
|
||||
|
||||
"home.banner.badge": "جديد",
|
||||
"home.banner.text": "نقدّم علامات التبويب لتطبيق سطح المكتب.",
|
||||
"home.banner.platforms": "متاحة على macOS وWindows وLinux",
|
||||
"home.banner.text": "تطبيق سطح المكتب متاح بنسخة تجريبية",
|
||||
"home.banner.platforms": "على macOS، Windows، وLinux",
|
||||
"home.banner.downloadNow": "حمّل الآن",
|
||||
"home.banner.downloadBetaNow": "حمّل النسخة التجريبية لتطبيق سطح المكتب الآن",
|
||||
"home.promo.title": "نقدّم علامات التبويب لتطبيق سطح المكتب",
|
||||
"home.promo.body": "نظّم عملك وجلساتك النشطة باستخدام علامات التبويب.",
|
||||
"home.promo.cta": "نزّل أحدث إصدار للبدء.",
|
||||
"home.promo.close": "إغلاق إعلان تطبيق سطح المكتب",
|
||||
|
||||
"home.hero.title": "وكيل برمجة بالذكاء الاصطناعي مفتوح المصدر",
|
||||
"home.hero.subtitle.a": "نماذج مجانية مضمّنة أو اربط أي نموذج من أي مزوّد،",
|
||||
@@ -704,11 +700,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | تنزيل",
|
||||
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",
|
||||
"download.hero.title": "تنزيل OpenCode لسطح المكتب",
|
||||
"download.hero.title": "تنزيل OpenCode",
|
||||
"download.hero.subtitle": "متاح في نسخة تجريبية لـ macOS، Windows، وLinux",
|
||||
"download.hero.button": "تنزيل لـ {{os}}",
|
||||
"download.section.terminal": "OpenCode للطرفية",
|
||||
"download.section.desktop": "OpenCode لسطح المكتب",
|
||||
"download.section.desktop": "OpenCode لسطح المكتب (Beta)",
|
||||
"download.section.extensions": "امتدادات OpenCode",
|
||||
"download.section.integrations": "تكاملات OpenCode",
|
||||
"download.action.download": "تنزيل",
|
||||
|
||||
@@ -102,14 +102,10 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "logo opencode escuro",
|
||||
|
||||
"home.banner.badge": "Novo",
|
||||
"home.banner.text": "Conheça as abas no app desktop.",
|
||||
"home.banner.platforms": "Disponível no macOS, Windows e Linux",
|
||||
"home.banner.text": "App desktop disponível em beta",
|
||||
"home.banner.platforms": "no macOS, Windows e Linux",
|
||||
"home.banner.downloadNow": "Baixar agora",
|
||||
"home.banner.downloadBetaNow": "Baixe agora o beta do desktop",
|
||||
"home.promo.title": "Conheça as abas no app desktop",
|
||||
"home.promo.body": "Organize seu trabalho e suas sessões ativas com abas.",
|
||||
"home.promo.cta": "Baixe a versão mais recente para começar.",
|
||||
"home.promo.close": "Fechar anúncio do app desktop",
|
||||
|
||||
"home.hero.title": "O agente de codificação de código aberto com IA",
|
||||
"home.hero.subtitle.a": "Modelos grátis incluídos ou conecte qualquer modelo de qualquer provedor,",
|
||||
@@ -715,11 +711,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | Baixar",
|
||||
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",
|
||||
"download.hero.title": "Baixar OpenCode Desktop",
|
||||
"download.hero.title": "Baixar OpenCode",
|
||||
"download.hero.subtitle": "Disponível em Beta para macOS, Windows e Linux",
|
||||
"download.hero.button": "Baixar para {{os}}",
|
||||
"download.section.terminal": "OpenCode Terminal",
|
||||
"download.section.desktop": "OpenCode Desktop",
|
||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
||||
"download.section.extensions": "Extensões OpenCode",
|
||||
"download.section.integrations": "Integrações OpenCode",
|
||||
"download.action.download": "Baixar",
|
||||
|
||||
@@ -102,14 +102,10 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "opencode logo dark",
|
||||
|
||||
"home.banner.badge": "Ny",
|
||||
"home.banner.text": "Introduktion af Tabs til Desktop.",
|
||||
"home.banner.platforms": "Tilgængelig på macOS, Windows og Linux",
|
||||
"home.banner.text": "Desktop-app tilgængelig i beta",
|
||||
"home.banner.platforms": "på macOS, Windows og Linux",
|
||||
"home.banner.downloadNow": "Download nu",
|
||||
"home.banner.downloadBetaNow": "Download desktop-betaen nu",
|
||||
"home.promo.title": "Introduktion af Tabs til Desktop",
|
||||
"home.promo.body": "Organiser dit arbejde og aktive sessioner med faner.",
|
||||
"home.promo.cta": "Download den nyeste version for at komme i gang.",
|
||||
"home.promo.close": "Luk meddelelsen om Desktop-appen",
|
||||
|
||||
"home.hero.title": "Den open source AI-kodningsagent",
|
||||
"home.hero.subtitle.a": "Gratis modeller inkluderet, eller forbind enhver model fra enhver udbyder,",
|
||||
@@ -710,11 +706,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Download OpenCode til macOS, Windows og Linux",
|
||||
"download.hero.title": "Download OpenCode Desktop",
|
||||
"download.hero.title": "Download OpenCode",
|
||||
"download.hero.subtitle": "Tilgængelig i beta til macOS, Windows og Linux",
|
||||
"download.hero.button": "Download til {{os}}",
|
||||
"download.section.terminal": "OpenCode Terminal",
|
||||
"download.section.desktop": "OpenCode Desktop",
|
||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
||||
"download.section.extensions": "OpenCode Extensions",
|
||||
"download.section.integrations": "OpenCode Integrations",
|
||||
"download.action.download": "Download",
|
||||
|
||||
@@ -102,14 +102,10 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "OpenCode Logo dunkel",
|
||||
|
||||
"home.banner.badge": "Neu",
|
||||
"home.banner.text": "Neu: Tabs für Desktop.",
|
||||
"home.banner.platforms": "Verfügbar für macOS, Windows und Linux",
|
||||
"home.banner.text": "Desktop-App in der Beta verfügbar",
|
||||
"home.banner.platforms": "auf macOS, Windows und Linux",
|
||||
"home.banner.downloadNow": "Jetzt herunterladen",
|
||||
"home.banner.downloadBetaNow": "Desktop-Beta jetzt herunterladen",
|
||||
"home.promo.title": "Neu: Tabs für Desktop",
|
||||
"home.promo.body": "Organisiere deine Arbeit und aktiven Sitzungen mit Tabs.",
|
||||
"home.promo.cta": "Lade die neueste Version herunter, um loszulegen.",
|
||||
"home.promo.close": "Ankündigung zur Desktop-App schließen",
|
||||
|
||||
"home.hero.title": "Der Open-Source AI-Coding-Agent",
|
||||
"home.hero.subtitle.a": "Kostenlose Modelle inklusive oder verbinde jedes Modell eines beliebigen Anbieters,",
|
||||
@@ -715,11 +711,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",
|
||||
"download.hero.title": "OpenCode Desktop herunterladen",
|
||||
"download.hero.title": "OpenCode herunterladen",
|
||||
"download.hero.subtitle": "In Beta verfügbar für macOS, Windows und Linux",
|
||||
"download.hero.button": "Download für {{os}}",
|
||||
"download.section.terminal": "OpenCode Terminal",
|
||||
"download.section.desktop": "OpenCode Desktop",
|
||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
||||
"download.section.extensions": "OpenCode Extensions",
|
||||
"download.section.integrations": "OpenCode Integrationen",
|
||||
"download.action.download": "Download",
|
||||
|
||||
@@ -102,16 +102,11 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "opencode logo dark",
|
||||
|
||||
"home.banner.badge": "New",
|
||||
"home.banner.text": "Introducing tabs for desktop.",
|
||||
"home.banner.platforms": "Available on macOS, Windows, and Linux",
|
||||
"home.banner.text": "Desktop app available in beta",
|
||||
"home.banner.platforms": "on macOS, Windows, and Linux",
|
||||
"home.banner.downloadNow": "Download now",
|
||||
"home.banner.downloadBetaNow": "Download the desktop beta now",
|
||||
|
||||
"home.promo.title": "Introducing Tabs for Desktop",
|
||||
"home.promo.body": "Organize your work and active sessions with tabs.",
|
||||
"home.promo.cta": "Download the latest to get started.",
|
||||
"home.promo.close": "Dismiss desktop app announcement",
|
||||
|
||||
"home.hero.title": "The open source AI coding agent",
|
||||
"home.hero.subtitle.a": "Free models included or connect any model from any provider,",
|
||||
"home.hero.subtitle.b": "including Claude, GPT, Gemini and more.",
|
||||
@@ -708,11 +703,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
|
||||
"download.hero.title": "Download OpenCode Desktop",
|
||||
"download.hero.title": "Download OpenCode",
|
||||
"download.hero.subtitle": "Available in Beta for macOS, Windows, and Linux",
|
||||
"download.hero.button": "Download for {{os}}",
|
||||
"download.section.terminal": "OpenCode Terminal",
|
||||
"download.section.desktop": "OpenCode Desktop",
|
||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
||||
"download.section.extensions": "OpenCode Extensions",
|
||||
"download.section.integrations": "OpenCode Integrations",
|
||||
"download.action.download": "Download",
|
||||
|
||||
@@ -102,14 +102,10 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "logo de opencode oscuro",
|
||||
|
||||
"home.banner.badge": "Nuevo",
|
||||
"home.banner.text": "Presentamos Tabs para Desktop.",
|
||||
"home.banner.platforms": "Disponible en macOS, Windows y Linux",
|
||||
"home.banner.text": "Aplicación de escritorio disponible en beta",
|
||||
"home.banner.platforms": "en macOS, Windows y Linux",
|
||||
"home.banner.downloadNow": "Descargar ahora",
|
||||
"home.banner.downloadBetaNow": "Descargar la beta de escritorio ahora",
|
||||
"home.promo.title": "Presentamos Tabs para Desktop",
|
||||
"home.promo.body": "Organiza tu trabajo y tus sesiones activas con pestañas.",
|
||||
"home.promo.cta": "Descarga la última versión para empezar.",
|
||||
"home.promo.close": "Cerrar el anuncio de la aplicación Desktop",
|
||||
|
||||
"home.hero.title": "El agente de codificación IA de código abierto",
|
||||
"home.hero.subtitle.a": "Modelos gratuitos incluidos o conecta cualquier modelo de cualquier proveedor,",
|
||||
@@ -715,11 +711,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | Descargar",
|
||||
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",
|
||||
"download.hero.title": "Descargar OpenCode Desktop",
|
||||
"download.hero.title": "Descargar OpenCode",
|
||||
"download.hero.subtitle": "Disponible en Beta para macOS, Windows y Linux",
|
||||
"download.hero.button": "Descargar para {{os}}",
|
||||
"download.section.terminal": "OpenCode Terminal",
|
||||
"download.section.desktop": "OpenCode Desktop",
|
||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
||||
"download.section.extensions": "Extensiones OpenCode",
|
||||
"download.section.integrations": "Integraciones OpenCode",
|
||||
"download.action.download": "Descargar",
|
||||
|
||||
@@ -100,14 +100,10 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "opencode logo dark",
|
||||
|
||||
"home.banner.badge": "Nouveau",
|
||||
"home.banner.text": "Découvrez Tabs pour Desktop.",
|
||||
"home.banner.platforms": "Disponible sur macOS, Windows et Linux",
|
||||
"home.banner.text": "Application desktop disponible en bêta",
|
||||
"home.banner.platforms": "sur macOS, Windows et Linux",
|
||||
"home.banner.downloadNow": "Télécharger maintenant",
|
||||
"home.banner.downloadBetaNow": "Télécharger la bêta desktop maintenant",
|
||||
"home.promo.title": "Découvrez Tabs pour Desktop",
|
||||
"home.promo.body": "Organisez votre travail et vos sessions actives avec des onglets.",
|
||||
"home.promo.cta": "Téléchargez la dernière version pour commencer.",
|
||||
"home.promo.close": "Fermer l’annonce de l’application Desktop",
|
||||
|
||||
"home.hero.title": "L'agent de code IA open source",
|
||||
"home.hero.subtitle.a":
|
||||
@@ -722,11 +718,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | Téléchargement",
|
||||
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",
|
||||
"download.hero.title": "Télécharger OpenCode Desktop",
|
||||
"download.hero.title": "Télécharger OpenCode",
|
||||
"download.hero.subtitle": "Disponible en bêta pour macOS, Windows et Linux",
|
||||
"download.hero.button": "Télécharger pour {{os}}",
|
||||
"download.section.terminal": "OpenCode Terminal",
|
||||
"download.section.desktop": "OpenCode Desktop",
|
||||
"download.section.desktop": "OpenCode Desktop (Bêta)",
|
||||
"download.section.extensions": "Extensions OpenCode",
|
||||
"download.section.integrations": "Intégrations OpenCode",
|
||||
"download.action.download": "Télécharger",
|
||||
|
||||
@@ -102,14 +102,10 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "logo scuro di opencode",
|
||||
|
||||
"home.banner.badge": "Nuovo",
|
||||
"home.banner.text": "Scopri Tabs per Desktop.",
|
||||
"home.banner.platforms": "Disponibile su macOS, Windows e Linux",
|
||||
"home.banner.text": "App desktop disponibile in beta",
|
||||
"home.banner.platforms": "su macOS, Windows e Linux",
|
||||
"home.banner.downloadNow": "Scarica ora",
|
||||
"home.banner.downloadBetaNow": "Scarica ora la beta desktop",
|
||||
"home.promo.title": "Scopri Tabs per Desktop",
|
||||
"home.promo.body": "Organizza il tuo lavoro e le sessioni attive con le schede.",
|
||||
"home.promo.cta": "Scarica l’ultima versione per iniziare.",
|
||||
"home.promo.close": "Chiudi l’annuncio dell’app Desktop",
|
||||
|
||||
"home.hero.title": "L'agente di coding IA open source",
|
||||
"home.hero.subtitle.a": "Modelli gratuiti inclusi o collega qualsiasi modello da qualsiasi provider,",
|
||||
@@ -713,11 +709,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",
|
||||
"download.hero.title": "Scarica OpenCode Desktop",
|
||||
"download.hero.title": "Scarica OpenCode",
|
||||
"download.hero.subtitle": "Disponibile in Beta per macOS, Windows e Linux",
|
||||
"download.hero.button": "Scarica per {{os}}",
|
||||
"download.section.terminal": "OpenCode Terminal",
|
||||
"download.section.desktop": "OpenCode Desktop",
|
||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
||||
"download.section.extensions": "OpenCode Extensions",
|
||||
"download.section.integrations": "OpenCode Integrations",
|
||||
"download.action.download": "Scarica",
|
||||
|
||||
@@ -102,14 +102,10 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "opencodeのロゴ(ダーク)",
|
||||
|
||||
"home.banner.badge": "新着",
|
||||
"home.banner.text": "デスクトップ版にタブが登場。",
|
||||
"home.banner.platforms": "macOS、Windows、Linux で利用できます",
|
||||
"home.banner.text": "デスクトップアプリのベータ版が利用可能",
|
||||
"home.banner.platforms": "macOS、Windows、Linux で",
|
||||
"home.banner.downloadNow": "今すぐダウンロード",
|
||||
"home.banner.downloadBetaNow": "デスクトップベータ版を今すぐダウンロード",
|
||||
"home.promo.title": "デスクトップ版にタブが登場",
|
||||
"home.promo.body": "タブで作業とアクティブなセッションを整理できます。",
|
||||
"home.promo.cta": "始めるには最新版をダウンロードしてください。",
|
||||
"home.promo.close": "デスクトップアプリのお知らせを閉じる",
|
||||
|
||||
"home.hero.title": "オープンソースのAIコーディングエージェント",
|
||||
"home.hero.subtitle.a": "無料モデルが含まれています。また、任意のプロバイダーの任意のモデルに接続でき、",
|
||||
@@ -712,11 +708,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | ダウンロード",
|
||||
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",
|
||||
"download.hero.title": "OpenCode デスクトップ版をダウンロード",
|
||||
"download.hero.title": "OpenCode をダウンロード",
|
||||
"download.hero.subtitle": "macOS、Windows、Linux 向けベータ版を利用可能",
|
||||
"download.hero.button": "{{os}} 向けダウンロード",
|
||||
"download.section.terminal": "OpenCode Terminal",
|
||||
"download.section.desktop": "OpenCode Desktop",
|
||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
||||
"download.section.extensions": "OpenCode Extensions",
|
||||
"download.section.integrations": "OpenCode Integrations",
|
||||
"download.action.download": "ダウンロード",
|
||||
|
||||
@@ -102,14 +102,10 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "opencode 어두운 로고",
|
||||
|
||||
"home.banner.badge": "신규",
|
||||
"home.banner.text": "데스크톱 탭을 소개합니다.",
|
||||
"home.banner.platforms": "macOS, Windows, Linux에서 사용할 수 있습니다",
|
||||
"home.banner.text": "데스크톱 앱 베타 버전 출시",
|
||||
"home.banner.platforms": "macOS, Windows, Linux 지원",
|
||||
"home.banner.downloadNow": "지금 다운로드",
|
||||
"home.banner.downloadBetaNow": "데스크톱 베타 다운로드",
|
||||
"home.promo.title": "데스크톱 탭을 소개합니다",
|
||||
"home.promo.body": "탭으로 작업과 활성 세션을 정리하세요.",
|
||||
"home.promo.cta": "최신 버전을 다운로드하여 시작하세요.",
|
||||
"home.promo.close": "데스크톱 앱 안내 닫기",
|
||||
|
||||
"home.hero.title": "오픈 소스 AI 코딩 에이전트",
|
||||
"home.hero.subtitle.a": "무료 모델이 포함되어 있으며, 어떤 제공자의 모델이든 연결 가능합니다.",
|
||||
@@ -704,11 +700,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | 다운로드",
|
||||
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",
|
||||
"download.hero.title": "OpenCode 데스크톱 다운로드",
|
||||
"download.hero.title": "OpenCode 다운로드",
|
||||
"download.hero.subtitle": "macOS, Windows, Linux용 베타 버전 사용 가능",
|
||||
"download.hero.button": "{{os}}용 다운로드",
|
||||
"download.section.terminal": "OpenCode 터미널",
|
||||
"download.section.desktop": "OpenCode 데스크톱",
|
||||
"download.section.desktop": "OpenCode 데스크톱 (베타)",
|
||||
"download.section.extensions": "OpenCode 확장 프로그램",
|
||||
"download.section.integrations": "OpenCode 통합",
|
||||
"download.action.download": "다운로드",
|
||||
|
||||
@@ -102,14 +102,10 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "opencode logo mørk",
|
||||
|
||||
"home.banner.badge": "Ny",
|
||||
"home.banner.text": "Vi introduserer Tabs for Desktop.",
|
||||
"home.banner.platforms": "Tilgjengelig på macOS, Windows og Linux",
|
||||
"home.banner.text": "Desktop-app tilgjengelig i beta",
|
||||
"home.banner.platforms": "på macOS, Windows og Linux",
|
||||
"home.banner.downloadNow": "Last ned nå",
|
||||
"home.banner.downloadBetaNow": "Last ned desktop-betaen nå",
|
||||
"home.promo.title": "Vi introduserer Tabs for Desktop",
|
||||
"home.promo.body": "Organiser arbeidet ditt og aktive økter med faner.",
|
||||
"home.promo.cta": "Last ned den nyeste versjonen for å komme i gang.",
|
||||
"home.promo.close": "Lukk kunngjøringen om Desktop-appen",
|
||||
|
||||
"home.hero.title": "Den åpne kildekode AI-kodingsagenten",
|
||||
"home.hero.subtitle.a":
|
||||
@@ -711,11 +707,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | Last ned",
|
||||
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",
|
||||
"download.hero.title": "Last ned OpenCode Desktop",
|
||||
"download.hero.title": "Last ned OpenCode",
|
||||
"download.hero.subtitle": "Tilgjengelig i beta for macOS, Windows og Linux",
|
||||
"download.hero.button": "Last ned for {{os}}",
|
||||
"download.section.terminal": "OpenCode Terminal",
|
||||
"download.section.desktop": "OpenCode Desktop",
|
||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
||||
"download.section.extensions": "OpenCode Extensions",
|
||||
"download.section.integrations": "OpenCode Integrations",
|
||||
"download.action.download": "Last ned",
|
||||
|
||||
@@ -101,14 +101,10 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "ciemne logo opencode",
|
||||
|
||||
"home.banner.badge": "Nowość",
|
||||
"home.banner.text": "Przedstawiamy karty w aplikacji desktopowej.",
|
||||
"home.banner.platforms": "Dostępne na macOS, Windows i Linux",
|
||||
"home.banner.text": "Aplikacja desktopowa dostępna w wersji beta",
|
||||
"home.banner.platforms": "na macOS, Windows i Linux",
|
||||
"home.banner.downloadNow": "Pobierz teraz",
|
||||
"home.banner.downloadBetaNow": "Pobierz betę wersji desktopowej",
|
||||
"home.promo.title": "Przedstawiamy karty w aplikacji desktopowej",
|
||||
"home.promo.body": "Organizuj swoją pracę i aktywne sesje za pomocą kart.",
|
||||
"home.promo.cta": "Pobierz najnowszą wersję, aby rozpocząć.",
|
||||
"home.promo.close": "Zamknij ogłoszenie o aplikacji desktopowej",
|
||||
|
||||
"home.hero.title": "Open source'owy agent AI do kodowania",
|
||||
"home.hero.subtitle.a": "Darmowe modele w zestawie lub podłącz dowolny model od dowolnego dostawcy,",
|
||||
@@ -712,11 +708,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | Pobierz",
|
||||
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",
|
||||
"download.hero.title": "Pobierz OpenCode Desktop",
|
||||
"download.hero.title": "Pobierz OpenCode",
|
||||
"download.hero.subtitle": "Dostępne w wersji Beta na macOS, Windows i Linux",
|
||||
"download.hero.button": "Pobierz na {{os}}",
|
||||
"download.section.terminal": "Terminal OpenCode",
|
||||
"download.section.desktop": "Pulpit OpenCode",
|
||||
"download.section.desktop": "Pulpit OpenCode (Beta)",
|
||||
"download.section.extensions": "Rozszerzenia OpenCode",
|
||||
"download.section.integrations": "Integracje OpenCode",
|
||||
"download.action.download": "Pobierz",
|
||||
|
||||
@@ -102,14 +102,10 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "темный логотип opencode",
|
||||
|
||||
"home.banner.badge": "Новое",
|
||||
"home.banner.text": "Представляем вкладки в десктопном приложении.",
|
||||
"home.banner.platforms": "Доступно на macOS, Windows и Linux",
|
||||
"home.banner.text": "Доступно десктопное приложение (бета)",
|
||||
"home.banner.platforms": "на macOS, Windows и Linux",
|
||||
"home.banner.downloadNow": "Скачать",
|
||||
"home.banner.downloadBetaNow": "Скачать бету для десктопа",
|
||||
"home.promo.title": "Представляем вкладки в десктопном приложении",
|
||||
"home.promo.body": "Организуйте работу и активные сессии с помощью вкладок.",
|
||||
"home.promo.cta": "Скачайте последнюю версию, чтобы начать.",
|
||||
"home.promo.close": "Закрыть объявление о десктопном приложении",
|
||||
|
||||
"home.hero.title": "AI-агент с открытым кодом для программирования",
|
||||
"home.hero.subtitle.a": "Бесплатные модели включены, или подключите любую модель от любого провайдера,",
|
||||
@@ -720,11 +716,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | Скачать",
|
||||
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",
|
||||
"download.hero.title": "Скачать OpenCode Desktop",
|
||||
"download.hero.title": "Скачать OpenCode",
|
||||
"download.hero.subtitle": "Доступна бета для macOS, Windows и Linux",
|
||||
"download.hero.button": "Скачать для {{os}}",
|
||||
"download.section.terminal": "OpenCode Terminal",
|
||||
"download.section.desktop": "OpenCode Desktop",
|
||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
||||
"download.section.extensions": "Расширения OpenCode",
|
||||
"download.section.integrations": "Интеграции OpenCode",
|
||||
"download.action.download": "Скачать",
|
||||
|
||||
@@ -102,14 +102,10 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "โลโก้ opencode แบบมืด",
|
||||
|
||||
"home.banner.badge": "ใหม่",
|
||||
"home.banner.text": "ขอแนะนำแท็บสำหรับเดสก์ท็อป.",
|
||||
"home.banner.platforms": "พร้อมใช้งานบน macOS, Windows และ Linux",
|
||||
"home.banner.text": "แอปเดสก์ท็อปพร้อมใช้งานในเวอร์ชันเบต้า",
|
||||
"home.banner.platforms": "บน macOS, Windows และ Linux",
|
||||
"home.banner.downloadNow": "ดาวน์โหลดตอนนี้",
|
||||
"home.banner.downloadBetaNow": "ดาวน์โหลดเบต้าเดสก์ท็อปตอนนี้",
|
||||
"home.promo.title": "ขอแนะนำแท็บสำหรับเดสก์ท็อป",
|
||||
"home.promo.body": "จัดระเบียบงานและเซสชันที่ใช้งานอยู่ด้วยแท็บ",
|
||||
"home.promo.cta": "ดาวน์โหลดเวอร์ชันล่าสุดเพื่อเริ่มต้นใช้งาน",
|
||||
"home.promo.close": "ปิดประกาศเกี่ยวกับแอปเดสก์ท็อป",
|
||||
|
||||
"home.hero.title": "เอเจนต์เขียนโค้ดด้วย AI แบบโอเพนซอร์ส",
|
||||
"home.hero.subtitle.a": "มีโมเดลฟรีรวมอยู่ หรือเชื่อมต่อโมเดลใดก็ได้จากผู้ให้บริการรายใดก็ได้,",
|
||||
@@ -707,11 +703,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | ดาวน์โหลด",
|
||||
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",
|
||||
"download.hero.title": "ดาวน์โหลด OpenCode สำหรับเดสก์ท็อป",
|
||||
"download.hero.title": "ดาวน์โหลด OpenCode",
|
||||
"download.hero.subtitle": "พร้อมใช้งานในเวอร์ชันเบต้าสำหรับ macOS, Windows และ Linux",
|
||||
"download.hero.button": "ดาวน์โหลดสำหรับ {{os}}",
|
||||
"download.section.terminal": "OpenCode Terminal",
|
||||
"download.section.desktop": "OpenCode Desktop",
|
||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
||||
"download.section.extensions": "ส่วนขยาย OpenCode",
|
||||
"download.section.integrations": "การเชื่อมต่อ OpenCode",
|
||||
"download.action.download": "ดาวน์โหลด",
|
||||
|
||||
@@ -102,14 +102,10 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "opencode koyu logo",
|
||||
|
||||
"home.banner.badge": "Yeni",
|
||||
"home.banner.text": "Masaüstü uygulamasında sekmelerle tanışın.",
|
||||
"home.banner.platforms": "macOS, Windows ve Linux'ta kullanılabilir",
|
||||
"home.banner.text": "Masaüstü uygulaması beta olarak mevcut",
|
||||
"home.banner.platforms": "macOS, Windows ve Linux'ta",
|
||||
"home.banner.downloadNow": "Şimdi indir",
|
||||
"home.banner.downloadBetaNow": "Masaüstü betayı şimdi indir",
|
||||
"home.promo.title": "Masaüstü uygulamasında sekmelerle tanışın",
|
||||
"home.promo.body": "Çalışmalarınızı ve etkin oturumlarınızı sekmelerle düzenleyin.",
|
||||
"home.promo.cta": "Başlamak için en son sürümü indirin.",
|
||||
"home.promo.close": "Masaüstü uygulaması duyurusunu kapat",
|
||||
|
||||
"home.hero.title": "Açık kaynaklı yapay zeka kodlama ajanı",
|
||||
"home.hero.subtitle.a": "Ücretsiz modeller dahil veya herhangi bir sağlayıcıdan herhangi bir modeli bağlayın,",
|
||||
@@ -715,11 +711,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | İndir",
|
||||
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",
|
||||
"download.hero.title": "OpenCode Desktop'u İndir",
|
||||
"download.hero.title": "OpenCode'u İndir",
|
||||
"download.hero.subtitle": "macOS, Windows ve Linux için Beta olarak sunuluyor",
|
||||
"download.hero.button": "{{os}} için indir",
|
||||
"download.section.terminal": "OpenCode Terminal",
|
||||
"download.section.desktop": "OpenCode Desktop",
|
||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
||||
"download.section.extensions": "OpenCode Eklentileri",
|
||||
"download.section.integrations": "OpenCode Entegrasyonları",
|
||||
"download.action.download": "İndir",
|
||||
|
||||
@@ -102,14 +102,10 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "темний логотип opencode",
|
||||
|
||||
"home.banner.badge": "Нове",
|
||||
"home.banner.text": "Представляємо вкладки в десктопному застосунку.",
|
||||
"home.banner.platforms": "Доступно на macOS, Windows та Linux",
|
||||
"home.banner.text": "Десктопний застосунок доступний у бета-версії",
|
||||
"home.banner.platforms": "на macOS, Windows та Linux",
|
||||
"home.banner.downloadNow": "Завантажити зараз",
|
||||
"home.banner.downloadBetaNow": "Завантажити бета-версію десктопного застосунку",
|
||||
"home.promo.title": "Представляємо вкладки в десктопному застосунку",
|
||||
"home.promo.body": "Упорядковуйте роботу й активні сесії за допомогою вкладок.",
|
||||
"home.promo.cta": "Завантажте останню версію, щоб почати.",
|
||||
"home.promo.close": "Закрити оголошення про десктопний застосунок",
|
||||
|
||||
"home.hero.title": "Відкритий AI-агент для кодування",
|
||||
"home.hero.subtitle.a": "Безкоштовні моделі включено або підключіть будь-яку модель від будь-якого провайдера,",
|
||||
@@ -674,11 +670,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | Завантажити",
|
||||
"download.meta.description": "Завантажте OpenCode для macOS, Windows та Linux",
|
||||
"download.hero.title": "Завантажити OpenCode Desktop",
|
||||
"download.hero.title": "Завантажити OpenCode",
|
||||
"download.hero.subtitle": "Доступно в бета-версії для macOS, Windows та Linux",
|
||||
"download.hero.button": "Завантажити для {{os}}",
|
||||
"download.section.terminal": "Термінал OpenCode",
|
||||
"download.section.desktop": "Десктоп OpenCode",
|
||||
"download.section.desktop": "Десктоп OpenCode (Бета)",
|
||||
"download.section.extensions": "Розширення OpenCode",
|
||||
"download.section.integrations": "Інтеграції OpenCode",
|
||||
"download.action.download": "Завантажити",
|
||||
|
||||
@@ -103,14 +103,10 @@ export const dict = {
|
||||
"temp.logoDarkAlt": "opencode logo 暗色",
|
||||
|
||||
"home.banner.badge": "新",
|
||||
"home.banner.text": "桌面版标签页现已推出。",
|
||||
"home.banner.platforms": "适用于 macOS、Windows 和 Linux",
|
||||
"home.banner.text": "桌面应用 Beta 版现已推出",
|
||||
"home.banner.platforms": "支持 macOS, Windows, 和 Linux",
|
||||
"home.banner.downloadNow": "立即下载",
|
||||
"home.banner.downloadBetaNow": "立即下载桌面 Beta 版",
|
||||
"home.promo.title": "桌面版标签页现已推出",
|
||||
"home.promo.body": "使用标签页整理工作和活跃会话。",
|
||||
"home.promo.cta": "下载最新版本即可开始使用。",
|
||||
"home.promo.close": "关闭桌面应用公告",
|
||||
|
||||
"home.hero.title": "开源 AI 编程代理",
|
||||
"home.hero.subtitle.a": "内置免费模型,或连接任意提供商的任意模型,",
|
||||
@@ -686,11 +682,11 @@ export const dict = {
|
||||
|
||||
"download.title": "OpenCode | 下载",
|
||||
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",
|
||||
"download.hero.title": "下载 OpenCode 桌面版",
|
||||
"download.hero.title": "下载 OpenCode",
|
||||
"download.hero.subtitle": "适用于 macOS, Windows, 和 Linux 的 Beta 版",
|
||||
"download.hero.button": "下载 {{os}} 版",
|
||||
"download.section.terminal": "OpenCode 终端",
|
||||
"download.section.desktop": "OpenCode 桌面版",
|
||||
"download.section.desktop": "OpenCode 桌面版 (Beta)",
|
||||
"download.section.extensions": "OpenCode 扩展",
|
||||
"download.section.integrations": "OpenCode 集成",
|
||||
"download.action.download": "下载",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user